@pmelab/gtd 64.2.0 → 65.0.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 +1 -1
- package/README.md +36 -26
- package/claude/hooks/assist.ts +9 -35
- package/claude/hooks/doors.ts +4 -4
- package/claude/hooks/git.ts +56 -0
- package/claude/hooks/handoff.ts +11 -11
- package/claude/hooks/jobs.ts +8 -8
- package/claude/hooks/pr.ts +8 -8
- package/claude/hooks/register.tsx +16 -78
- package/claude/hooks/tools.ts +7 -20
- package/claude/hooks/wording.ts +3 -3
- package/dist/chunks/{flows-dnIE_qbe.mjs → flows-DGs0W3HI.mjs} +19 -2
- package/dist/chunks/{testkit-BwCH8ky6.mjs → testkit-CgrLmpdJ.mjs} +741 -80
- package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-BNS8ciNu.mjs} +5 -3
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +565 -114
- package/dist/gtd.core.mjs +132 -129
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +2 -2
- package/dist/web/main.js +2 -2
- package/dist/wire.mjs +1 -1
- package/dist/workflow-kit.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.ts +5 -5
- package/src/cli/History.test.ts +10 -8
- package/src/cli/Metrics.test.ts +2 -1
- package/src/cli/Program.test.ts +4 -2
- package/src/cli/Program.ts +87 -5
- package/src/edge/Edge.test.ts +37 -16
- package/src/edge/Edge.ts +296 -31
- package/src/emit/GitScript.ts +116 -15
- package/src/emit/index.ts +7 -0
- package/src/flows/config.ts +7 -1
- package/src/flows/index.test.ts +1 -0
- package/src/flows/index.ts +2 -0
- package/src/flows/runtime.ts +32 -1
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Replay.test.ts +9 -0
- package/src/replay/Replay.ts +59 -15
- package/src/replay/Trailers.ts +14 -0
- package/src/replay/index.ts +1 -1
- package/src/step/LandStep.ts +12 -0
- package/src/step/RepoSnapshot.ts +11 -0
- package/src/step/index.ts +1 -0
- package/src/step/planStart.test.ts +4 -3
- package/src/step/planStart.ts +7 -0
- package/src/step/planStep.ts +17 -12
- package/src/step/startBranch.ts +54 -0
- package/src/twins/EmittedScriptRecognizer.ts +117 -0
- package/src/twins/EmittedScripts.test.ts +147 -1
- package/src/twins/InMemRepo.ts +116 -0
- package/src/wire/Demand.ts +22 -10
- package/src/wire/constants.ts +3 -0
- package/src/wire/types.ts +4 -0
- package/src/workflow/Workflow.ts +1 -0
- package/src/workflow/compile.ts +2 -0
- package/src/workflow/load.ts +2 -0
- package/claude/hooks/ship.ts +0 -730
package/README.md
CHANGED
|
@@ -362,24 +362,19 @@ questions one at a time, answers your own questions about the plan or the diff,
|
|
|
362
362
|
records what you decide in the steering file the way the step asks, and moves
|
|
363
363
|
the process on only when you say so; a review you leave untouched signs off.
|
|
364
364
|
While a step runs you can keep asking; requests for changes wait for the next
|
|
365
|
-
rest.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
run in the background, with progress on the status line; they report by waking
|
|
379
|
-
Claude, a toast, a transcript line and a push notification, and a failed push or
|
|
380
|
-
`gh` call reports as a failure. While a ship or hand-off runs or is queued,
|
|
381
|
-
`/gtd catch`, the doors, `/gtd <requirements>` and bare `/gtd` wait for its
|
|
382
|
-
report. See
|
|
365
|
+
rest. A process can change hands at any gate: ask Claude to hand it off (or run
|
|
366
|
+
`/gtd throw @dev`) and it opens a draft pull request assigned to them, and
|
|
367
|
+
`/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
|
|
368
|
+
requirements can hand the build to someone else. `/gtd pr` brings a pull
|
|
369
|
+
request's unresolved review threads into the review: each becomes a note in
|
|
370
|
+
`.gtd/REVIEW.md` ticked "fix as suggested" (untick it for "won't fix" plus a
|
|
371
|
+
reason, or your own answer), the review door fixes them in place, and each
|
|
372
|
+
thread then gets a reply naming the fixing commit (or your reason) and is
|
|
373
|
+
resolved. Hand-off and throw answer at once and run in the background, with
|
|
374
|
+
progress on the status line; they report by waking Claude, a toast, a transcript
|
|
375
|
+
line and a push notification, and a failed push or `gh` call reports as a
|
|
376
|
+
failure. While a hand-off runs or is queued, `/gtd catch`, the doors,
|
|
377
|
+
`/gtd <requirements>` and bare `/gtd` wait for its report. See
|
|
383
378
|
[Inside Claude Code](https://github.com/pmelab/gtd/blob/main/docs/driver.md#inside-claude-code-the-gtd-mod).
|
|
384
379
|
|
|
385
380
|
### Then let an agent build your own
|
|
@@ -450,7 +445,8 @@ to answer, reopening it edits your last entry instead. Tap an already-answered
|
|
|
450
445
|
question to leave a note on it; the agent may overturn the answer. A `findings`
|
|
451
446
|
file lists its findings, each with a fix checkbox and, once ticked, an optional
|
|
452
447
|
line of guidance; Done stays disabled while the file holds anything its gate
|
|
453
|
-
would refuse.
|
|
448
|
+
would refuse. Done saves and leaves the server running; it exits once the
|
|
449
|
+
worktree moves on to another step. See
|
|
454
450
|
[Configuration](https://github.com/pmelab/gtd/blob/main/docs/configuration.md)
|
|
455
451
|
for its `ui:` settings.
|
|
456
452
|
|
|
@@ -498,15 +494,29 @@ or `.gitmodules` `ignore` say. Works in any git repository, no process needed.
|
|
|
498
494
|
|
|
499
495
|
## Process history
|
|
500
496
|
|
|
497
|
+
Every process runs on the branch `gtd/<b>`, never on your branch `<b>`: starting
|
|
498
|
+
creates it (or reuses one whose commits are on `<b>` or in a kept history ref;
|
|
499
|
+
any other `gtd/<b>` is refused). A finished process switches HEAD back to `<b>`,
|
|
500
|
+
and gtd moves `<b>` only when the workflow calls `ship()`, which squashes the
|
|
501
|
+
process onto `<b>` as one commit (see
|
|
502
|
+
[Shipping](docs/configuration.md#shipping)); what it committed after its last
|
|
503
|
+
ship stays on `gtd/<b>`. `gtd abandon` switches back to `<b>` with the unshipped
|
|
504
|
+
work uncommitted and deletes `gtd/<b>`; `gtd restore` recreates it. The closing
|
|
505
|
+
commit records `Gtd-Branch: <b>`.
|
|
506
|
+
|
|
501
507
|
When a process finishes, the landing that closes it also keeps the whole run
|
|
502
508
|
behind a ref, `refs/gtd/history/<entry>`, so a squash or a deleted branch
|
|
503
|
-
doesn't lose it.
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
ref. If that push fails, the ship fails and nothing is squashed. Recover the
|
|
507
|
-
full trail with `s=$(gtd history fetch --json=script) && sh -c "$s"`, then
|
|
509
|
+
doesn't lose it. A `ship()` step writes the ref too, and names it in a
|
|
510
|
+
`Gtd-History: <ref>` trailer on the commit it puts on `<b>`. Recover the full
|
|
511
|
+
trail with `s=$(gtd history fetch --json=script) && sh -c "$s"`, then
|
|
508
512
|
`gtd history show <ref>`.
|
|
509
513
|
|
|
514
|
+
`gtd abandon` keeps `refs/gtd/history/<entry>` at the abandoned tip too; history
|
|
515
|
+
lists and counts it like a finished one. Set `historyRemote` in the `config`
|
|
516
|
+
export to a remote name and every finish, ship and abandon force-pushes its ref
|
|
517
|
+
there; a failed push exits non-zero after the commit landed and prints the
|
|
518
|
+
retry.
|
|
519
|
+
|
|
510
520
|
```sh
|
|
511
521
|
gtd history list # one line per kept process
|
|
512
522
|
gtd history show <ref> # replay its steps, judge verdicts and approvals
|
|
@@ -585,7 +595,7 @@ gtd history judges --jsonl > judges.jsonl
|
|
|
585
595
|
```
|
|
586
596
|
|
|
587
597
|
`gtd history fetch` only prints a fetch script; pipe it into `sh` to run it.
|
|
588
|
-
|
|
598
|
+
Without `historyRemote`, refs are not pushed or fetched by default. The ref
|
|
589
599
|
holds every step commit, including steering files and code a later step removed,
|
|
590
600
|
so anyone who can fetch the remote can read it. To share the rest:
|
|
591
601
|
|
|
@@ -596,7 +606,7 @@ git config --add remote.origin.fetch 'refs/gtd/history/*:refs/gtd/history/*'
|
|
|
596
606
|
|
|
597
607
|
The second line makes every plain `git fetch` include them. It has no leading
|
|
598
608
|
`+`, so a conflicting local ref fails instead of being overwritten: after a
|
|
599
|
-
forced
|
|
609
|
+
forced `historyRemote` push, a clone holding the old tip deletes its local ref
|
|
600
610
|
(`git update-ref -d <ref>`) and fetches again.
|
|
601
611
|
|
|
602
612
|
A step declared with `keep: true` files its turn under `refs/gtd/kept/` instead
|
package/claude/hooks/assist.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { Stop } from "../types"
|
|
7
7
|
import { RELOAD_CUT_SCRIPT } from "./wording"
|
|
8
|
-
import type {
|
|
8
|
+
import type { Done } from "./git"
|
|
9
9
|
import { JOB_NAME, TOOL } from "./tools"
|
|
10
10
|
import type { JobName } from "./tools"
|
|
11
11
|
|
|
@@ -23,16 +23,12 @@ export type Wake = {
|
|
|
23
23
|
isRepeat?: boolean
|
|
24
24
|
// the judge questions an undecided judge gate puts to the person
|
|
25
25
|
questions?: Asked[]
|
|
26
|
-
// the branch a finished process can open its pull request from
|
|
27
|
-
branch?: string
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
// No wake for a stop the person asked for
|
|
31
|
-
|
|
32
|
-
export function wakes(stop: Stop, branch: string | undefined): boolean {
|
|
28
|
+
// No wake for a finished process or a stop the person asked for.
|
|
29
|
+
export function wakes(stop: Stop): boolean {
|
|
33
30
|
if (stop.kind === "stopped") return stop.text === RELOAD_CUT_SCRIPT
|
|
34
|
-
|
|
35
|
-
return true
|
|
31
|
+
return stop.kind !== "done"
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
const step = (s: Stop) => s.label ?? s.state ?? "the current step"
|
|
@@ -116,23 +112,9 @@ function trouble(w: Wake) {
|
|
|
116
112
|
]
|
|
117
113
|
}
|
|
118
114
|
|
|
119
|
-
function finished(w: Wake) {
|
|
120
|
-
return [
|
|
121
|
-
`gtd finished the process on branch ${w.branch}.`,
|
|
122
|
-
`Tell the person in two or three lines what was built (read the branch's commits), then ask whether to squash it and open its pull request(s). On yes, call ${TOOL.ship}.`,
|
|
123
|
-
]
|
|
124
|
-
}
|
|
125
|
-
|
|
126
115
|
export function wakePrompt(w: Wake): string {
|
|
127
116
|
const { stop } = w
|
|
128
|
-
const body =
|
|
129
|
-
stop.kind === "done"
|
|
130
|
-
? finished(w)
|
|
131
|
-
: stop.kind === "gate"
|
|
132
|
-
? stop.isJudge
|
|
133
|
-
? judged(w)
|
|
134
|
-
: gate(w)
|
|
135
|
-
: trouble(w)
|
|
117
|
+
const body = stop.kind === "gate" ? (stop.isJudge ? judged(w) : gate(w)) : trouble(w)
|
|
136
118
|
const notes = w.notes?.length
|
|
137
119
|
? [
|
|
138
120
|
`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")}`,
|
|
@@ -142,9 +124,9 @@ export function wakePrompt(w: Wake): string {
|
|
|
142
124
|
return [...body, ...notes, RULES, extras].join("\n\n")
|
|
143
125
|
}
|
|
144
126
|
|
|
145
|
-
// The wake after a background
|
|
127
|
+
// The wake after a background hand-off ended. It must not forbid further work:
|
|
146
128
|
// an agent with standing instructions (a review loop, a CI watch) would go idle.
|
|
147
|
-
export function jobPrompt(job: JobName, outcome:
|
|
129
|
+
export function jobPrompt(job: JobName, outcome: Done): string {
|
|
148
130
|
const name = JOB_NAME[job]
|
|
149
131
|
if (outcome.ok) {
|
|
150
132
|
return [
|
|
@@ -153,17 +135,9 @@ export function jobPrompt(job: JobName, outcome: Shipped): string {
|
|
|
153
135
|
"Relay the result to the person in a line or two, with any link in it; then continue with any work the person already asked for.",
|
|
154
136
|
].join("\n\n")
|
|
155
137
|
}
|
|
156
|
-
|
|
138
|
+
return [
|
|
157
139
|
`The ${name} the person asked for failed. gtd reports the failed step:`,
|
|
158
140
|
fence(outcome.text),
|
|
159
|
-
]
|
|
160
|
-
if (job === "ship") {
|
|
161
|
-
lines.push(
|
|
162
|
-
`If the commits were already squashed, they stay squashed: running /gtd ship again pushes the already-squashed branch.`,
|
|
163
|
-
)
|
|
164
|
-
}
|
|
165
|
-
lines.push(
|
|
166
141
|
"Tell the person what failed in plain words and what to try. Do not retry it yourself.",
|
|
167
|
-
)
|
|
168
|
-
return lines.join("\n\n")
|
|
142
|
+
].join("\n\n")
|
|
169
143
|
}
|
package/claude/hooks/doors.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// prints the script that starts one; running it is the driver's job, as with
|
|
3
3
|
// every git write gtd plans. The mod hardcodes no door: it asks gtd.
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type { GitIo, Done } from "./git"
|
|
6
6
|
|
|
7
7
|
export type Door = {
|
|
8
8
|
name: string
|
|
@@ -10,12 +10,12 @@ export type Door = {
|
|
|
10
10
|
args: { name: string; optional: boolean }[]
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
const fail = (text: string):
|
|
13
|
+
const fail = (text: string): Done => ({ ok: false, text })
|
|
14
14
|
|
|
15
15
|
type Startable = { kind: "workflow"; name: string } | ({ kind: "door" } & Door)
|
|
16
16
|
|
|
17
17
|
// Every door the repository offers; none when gtd cannot say.
|
|
18
|
-
export async function doors(io:
|
|
18
|
+
export async function doors(io: GitIo): Promise<Door[]> {
|
|
19
19
|
const listed = await io.run(["gtd", "start", "--json"])
|
|
20
20
|
if (listed.code !== 0) return []
|
|
21
21
|
try {
|
|
@@ -27,7 +27,7 @@ export async function doors(io: ShipIo): Promise<Door[]> {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export async function enter(io:
|
|
30
|
+
export async function enter(io: GitIo, name: string, args: string[]): Promise<Done> {
|
|
31
31
|
const argv = ["gtd", "start", name, ...args]
|
|
32
32
|
const planned = await io.run(argv)
|
|
33
33
|
if (planned.code !== 0)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type Ran = { code: number; out: string; err: string }
|
|
2
|
+
|
|
3
|
+
export type GitIo = {
|
|
4
|
+
// argv runs in the repository root; stdin is the child's whole input
|
|
5
|
+
run(argv: string[], stdin?: string): Promise<Ran>
|
|
6
|
+
log(line: string): void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type Done = { ok: boolean; text: string }
|
|
10
|
+
|
|
11
|
+
// A rejection becomes `killed`, so the caller can tell a timeout from a refused
|
|
12
|
+
// push. Relies on the host's process runner rejecting at its timeout; a spawn
|
|
13
|
+
// failure lands here too. Only pushes: any other crashed read must abort, not
|
|
14
|
+
// pass as an answer.
|
|
15
|
+
const attempt = async (io: GitIo, argv: string[]): Promise<Ran & { killed: boolean }> => {
|
|
16
|
+
try {
|
|
17
|
+
return { ...(await io.run(argv)), killed: false }
|
|
18
|
+
} catch (err) {
|
|
19
|
+
return { code: 1, out: "", err: err instanceof Error ? err.message : String(err), killed: true }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function defaultBranch(git: (...args: string[]) => Promise<string>) {
|
|
24
|
+
const head = await git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
|
|
25
|
+
return head.replace(/^origin\//, "") || "main"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A pull request describes what the remote branch holds. A rewritten branch
|
|
29
|
+
// needs --force-with-lease on an existing origin/<branch>. `fresh`: origin
|
|
30
|
+
// lacks the branch, so any tracking ref is stale. Returns why the push failed,
|
|
31
|
+
// or nothing.
|
|
32
|
+
export async function pushBranch(io: GitIo, branch: string, fresh = false) {
|
|
33
|
+
const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
|
|
34
|
+
const push = async (args: string[], failure: string) => {
|
|
35
|
+
const r = await attempt(io, ["git", "push", ...args])
|
|
36
|
+
if (r.killed)
|
|
37
|
+
return `git push of ${branch} did not finish; nothing was lost. Retry without fetching.\n${r.err}`.trimEnd()
|
|
38
|
+
if (r.code !== 0) return `${failure}\n${r.err}`.trimEnd()
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
if (fresh || !(await ok("rev-parse", "--verify", "--quiet", `origin/${branch}`))) {
|
|
42
|
+
io.log(`Pushing ${branch} to origin…`)
|
|
43
|
+
return push(["--set-upstream", "origin", branch], "git push failed.")
|
|
44
|
+
}
|
|
45
|
+
if (!(await ok("merge-base", "--is-ancestor", "HEAD", `origin/${branch}`))) {
|
|
46
|
+
io.log(`Pushing ${branch} to origin…`)
|
|
47
|
+
return push(
|
|
48
|
+
["--force-with-lease", "origin", branch],
|
|
49
|
+
`git push failed: origin/${branch} moved. Fetch and reconcile first.`,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Marks a draft pull request `/gtd throw` opened for a hand-off.
|
|
56
|
+
export const THROWN = "<!-- gtd:thrown -->"
|
package/claude/hooks/handoff.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// pushing the branch and telling them. `throw` opens (or refreshes) a draft
|
|
3
3
|
// pull request for the hand-off; `catch` checks it out wherever it was thrown.
|
|
4
4
|
|
|
5
|
-
import { defaultBranch, pushBranch, THROWN } from "./
|
|
6
|
-
import type {
|
|
5
|
+
import { defaultBranch, pushBranch, THROWN } from "./git"
|
|
6
|
+
import type { GitIo, Done } from "./git"
|
|
7
7
|
|
|
8
8
|
export type Rest = { state?: string; label?: string; content?: string; isIdle: boolean }
|
|
9
9
|
|
|
10
|
-
const fail = (text: string):
|
|
10
|
+
const fail = (text: string): Done => ({ ok: false, text })
|
|
11
11
|
|
|
12
12
|
type Pr = {
|
|
13
13
|
number: number
|
|
@@ -18,11 +18,11 @@ type Pr = {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export async function throwTo(
|
|
21
|
-
io:
|
|
21
|
+
io: GitIo,
|
|
22
22
|
rest: Rest,
|
|
23
23
|
target: string | undefined,
|
|
24
24
|
now: string,
|
|
25
|
-
): Promise<
|
|
25
|
+
): Promise<Done> {
|
|
26
26
|
if (rest.isIdle) return fail("Nothing is in progress, so there is nothing to throw.")
|
|
27
27
|
// Only landed state travels: an edit still in the tree is half an answer.
|
|
28
28
|
if ((await io.run(["git", "status", "--porcelain"])).out.trim()) {
|
|
@@ -59,7 +59,7 @@ export async function throwTo(
|
|
|
59
59
|
// The catcher checks out what origin holds, so a throw describing local HEAD
|
|
60
60
|
// must not leave origin elsewhere: fetch first, a stale tracking ref can hide
|
|
61
61
|
// a teammate's commits or a rewind. Returns why the throw refuses, or nothing.
|
|
62
|
-
async function remoteAhead(io:
|
|
62
|
+
async function remoteAhead(io: GitIo, branch: string) {
|
|
63
63
|
const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
|
|
64
64
|
if (!(await ok("fetch", "--prune", "origin"))) return "git fetch failed."
|
|
65
65
|
if (!(await ok("rev-parse", "--verify", "--quiet", `origin/${branch}`))) return undefined
|
|
@@ -69,7 +69,7 @@ async function remoteAhead(io: ShipIo, branch: string) {
|
|
|
69
69
|
|
|
70
70
|
// The branch to throw, moving the process off the default branch onto a new
|
|
71
71
|
// one when it sits there; a string is why that failed.
|
|
72
|
-
async function throwBranch(io:
|
|
72
|
+
async function throwBranch(io: GitIo, now: string) {
|
|
73
73
|
const git = async (...args: string[]) => (await io.run(["git", ...args])).out.trim()
|
|
74
74
|
const branch = await git("rev-parse", "--abbrev-ref", "HEAD")
|
|
75
75
|
if (!branch || branch === "HEAD") return "Detached HEAD: there is no branch to throw."
|
|
@@ -103,7 +103,7 @@ ${content.slice(0, 6000)}
|
|
|
103
103
|
// Opens the hand-off's draft, or refreshes a thrown one; a string is why that
|
|
104
104
|
// failed.
|
|
105
105
|
async function draft(
|
|
106
|
-
io:
|
|
106
|
+
io: GitIo,
|
|
107
107
|
branch: string,
|
|
108
108
|
base: string,
|
|
109
109
|
rest: Rest,
|
|
@@ -144,7 +144,7 @@ async function draft(
|
|
|
144
144
|
const pr = JSON.parse(view.out) as Pr
|
|
145
145
|
if (pr.state !== "OPEN") return `The pull request for ${branch} is ${pr.state}, not open.`
|
|
146
146
|
// A pull request someone opened for review keeps its own description and
|
|
147
|
-
// stays ready: only
|
|
147
|
+
// stays ready: only a thrown one is rewritten and made a draft again.
|
|
148
148
|
if (pr.body?.includes(THROWN)) {
|
|
149
149
|
const edited = await io.run(["gh", "pr", "edit", String(pr.number), "--body-file", "-"], body)
|
|
150
150
|
if (edited.code !== 0) return `gh pr edit failed; the pull request is unchanged.\n${edited.err}`
|
|
@@ -155,7 +155,7 @@ async function draft(
|
|
|
155
155
|
return pr
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
async function assign(io:
|
|
158
|
+
async function assign(io: GitIo, pr: Pr, handle: string) {
|
|
159
159
|
const stale = (pr.assignees ?? []).map((a) => a.login).filter((login) => login !== handle)
|
|
160
160
|
const args = ["gh", "pr", "edit", String(pr.number), "--add-assignee", handle]
|
|
161
161
|
for (const login of stale) args.push("--remove-assignee", login)
|
|
@@ -165,7 +165,7 @@ async function assign(io: ShipIo, pr: Pr, handle: string) {
|
|
|
165
165
|
: `Thrown to ${pr.url}, but assigning @${handle} failed.\n${assigned.err}`
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
export async function catchFrom(io:
|
|
168
|
+
export async function catchFrom(io: GitIo, ref: string): Promise<Done> {
|
|
169
169
|
if ((await io.run(["git", "status", "--porcelain"])).out.trim()) {
|
|
170
170
|
return fail("The working tree has edits. Commit or stash them before catching.")
|
|
171
171
|
}
|
package/claude/hooks/jobs.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// The one background slot
|
|
2
|
-
//
|
|
1
|
+
// The one background slot for a hand-off: it pushes the branch, so two at
|
|
2
|
+
// once would race. No global deadline on purpose: a deadline that freed
|
|
3
3
|
// the slot while the work still ran would let a second push overwrite the first.
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type { Done } from "./git"
|
|
6
6
|
import type { JobName } from "./tools"
|
|
7
7
|
import { JOB_NAME } from "./tools"
|
|
8
8
|
|
|
@@ -13,9 +13,9 @@ export type Jobs = {
|
|
|
13
13
|
// running or queued.
|
|
14
14
|
start(
|
|
15
15
|
job: JobName,
|
|
16
|
-
work: () => Promise<
|
|
16
|
+
work: () => Promise<Done>,
|
|
17
17
|
isInTurn: boolean,
|
|
18
|
-
done: (outcome:
|
|
18
|
+
done: (outcome: Done) => Promise<void>,
|
|
19
19
|
): string
|
|
20
20
|
flush(): void
|
|
21
21
|
busy(): JobName | undefined
|
|
@@ -25,8 +25,8 @@ export function jobs(): Jobs {
|
|
|
25
25
|
let current:
|
|
26
26
|
| {
|
|
27
27
|
job: JobName
|
|
28
|
-
work: () => Promise<
|
|
29
|
-
done: (outcome:
|
|
28
|
+
work: () => Promise<Done>
|
|
29
|
+
done: (outcome: Done) => Promise<void>
|
|
30
30
|
isRunning: boolean
|
|
31
31
|
}
|
|
32
32
|
| undefined
|
|
@@ -36,7 +36,7 @@ export function jobs(): Jobs {
|
|
|
36
36
|
if (!slot || slot.isRunning) return
|
|
37
37
|
slot.isRunning = true
|
|
38
38
|
void (async () => {
|
|
39
|
-
let outcome:
|
|
39
|
+
let outcome: Done
|
|
40
40
|
try {
|
|
41
41
|
outcome = await slot.work()
|
|
42
42
|
} catch (err) {
|
package/claude/hooks/pr.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// reads and writes the threads, and gtd only sees the notes.
|
|
5
5
|
|
|
6
6
|
import type { PrFeedback, PrThread } from "../types"
|
|
7
|
-
import { pushBranch } from "./
|
|
8
|
-
import type {
|
|
7
|
+
import { pushBranch } from "./git"
|
|
8
|
+
import type { GitIo, Done } from "./git"
|
|
9
9
|
|
|
10
10
|
export const REVIEW_FILE = ".gtd/REVIEW.md"
|
|
11
11
|
|
|
@@ -27,9 +27,9 @@ const TAG = /\(thread ([A-Za-z0-9_=-]+)\)\s*$/
|
|
|
27
27
|
const LEAD = "Tick exactly one:"
|
|
28
28
|
const PLACEHOLDER = "_your answer_"
|
|
29
29
|
|
|
30
|
-
const fail = (text: string):
|
|
30
|
+
const fail = (text: string): Done => ({ ok: false, text })
|
|
31
31
|
|
|
32
|
-
export async function currentPr(io:
|
|
32
|
+
export async function currentPr(io: GitIo): Promise<Pr | string> {
|
|
33
33
|
const branch = (await io.run(["git", "rev-parse", "--abbrev-ref", "HEAD"])).out.trim()
|
|
34
34
|
const v = await io.run(["gh", "pr", "view", "--json", "number,url"])
|
|
35
35
|
if (v.code !== 0) return `gh pr view failed: ${v.err.trim()}`
|
|
@@ -69,7 +69,7 @@ type ThreadNode = {
|
|
|
69
69
|
comments: { nodes: { author: { login: string } | null; body: string; url: string }[] }
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
export async function unresolvedThreads(io:
|
|
72
|
+
export async function unresolvedThreads(io: GitIo, pr: Pr): Promise<Thread[] | string> {
|
|
73
73
|
const r = await io.run([
|
|
74
74
|
"gh",
|
|
75
75
|
"api",
|
|
@@ -226,7 +226,7 @@ export function missingReasons(review: string) {
|
|
|
226
226
|
|
|
227
227
|
// The first commit since `since` whose review answers any of `ids`: the gate
|
|
228
228
|
// that decided them. Undefined while none has landed.
|
|
229
|
-
export async function decided(io:
|
|
229
|
+
export async function decided(io: GitIo, since: string, ids: readonly string[]) {
|
|
230
230
|
// A reset or rebase since the import leaves nothing to find the gate in.
|
|
231
231
|
if ((await io.run(["git", "merge-base", "--is-ancestor", since, "HEAD"])).code !== 0)
|
|
232
232
|
return "gone"
|
|
@@ -279,11 +279,11 @@ const RESOLVE = `mutation($thread: ID!) {
|
|
|
279
279
|
// to each answered thread and resolves it. The code commits after `gate` are
|
|
280
280
|
// the fixes; a thread that fails is named and left open.
|
|
281
281
|
export async function reply(
|
|
282
|
-
io:
|
|
282
|
+
io: GitIo,
|
|
283
283
|
host: string,
|
|
284
284
|
gate: string,
|
|
285
285
|
decidedAnswers: readonly Answer[],
|
|
286
|
-
): Promise<
|
|
286
|
+
): Promise<Done & { isPushed: boolean }> {
|
|
287
287
|
const branch = (await io.run(["git", "rev-parse", "--abbrev-ref", "HEAD"])).out.trim()
|
|
288
288
|
const pushed = await pushBranch(io, branch)
|
|
289
289
|
if (pushed) return { ...fail(pushed), isPushed: false }
|