@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
package/README.md
CHANGED
|
@@ -21,7 +21,9 @@ npm install -g @pmelab/gtd
|
|
|
21
21
|
|
|
22
22
|
Or run without installing (prefix every `gtd` below with `npx`) — see
|
|
23
23
|
[Configuration](https://github.com/pmelab/gtd/blob/main/docs/configuration.md)
|
|
24
|
-
for the settings most projects tune.
|
|
24
|
+
for the settings most projects tune. A process setting's value is committed to
|
|
25
|
+
Git history; keep secrets in an environment setting (see
|
|
26
|
+
[Settings](https://github.com/pmelab/gtd/blob/main/docs/configuration.md#settings)).
|
|
25
27
|
|
|
26
28
|
Also install all three skill sources the bundled workflow names in its prompts —
|
|
27
29
|
none is optional. See
|
|
@@ -276,6 +278,34 @@ this with your environment. See
|
|
|
276
278
|
[Driving the loop](https://github.com/pmelab/gtd/blob/main/docs/driver.md) for
|
|
277
279
|
the full protocol.
|
|
278
280
|
|
|
281
|
+
### Or drive it from inside Claude Code
|
|
282
|
+
|
|
283
|
+
The gtd mod runs the same loop inside an interactive Claude Code session
|
|
284
|
+
(2.1.287 or later), without `claude -p`. The plugin is the npm package itself,
|
|
285
|
+
so it brings its own gtd; it needs only `node` and `npm` on your `PATH`:
|
|
286
|
+
|
|
287
|
+
```sh
|
|
288
|
+
claude plugin marketplace add pmelab/gtd
|
|
289
|
+
claude plugin install gtd@gtd
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Then, in any repository:
|
|
293
|
+
|
|
294
|
+
```
|
|
295
|
+
/gtd Add a --json flag to the export command
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
That starts a process from your requirements and drives it until it needs you.
|
|
299
|
+
`/gtd fix` and `/gtd review [base]` take the two side doors described below.
|
|
300
|
+
Every human rest opens Claude Code's own question dialog, with a link to
|
|
301
|
+
`gtd ui` for the step; review there, then answer **I'm done, continue**. When
|
|
302
|
+
the process finishes, **Yes, open the pull request** (or `/gtd ship`) squashes
|
|
303
|
+
it into one commit and opens it. A process can change hands at any gate: **Hand
|
|
304
|
+
off to someone else** (or `/gtd throw @dev`) opens a draft pull request assigned
|
|
305
|
+
to them, and `/gtd catch <pr>` picks it up exactly where it waits, so whoever
|
|
306
|
+
wrote the requirements can hand the architecture to someone else. See
|
|
307
|
+
[Inside Claude Code](https://github.com/pmelab/gtd/blob/main/docs/driver.md#inside-claude-code-the-gtd-mod).
|
|
308
|
+
|
|
279
309
|
### Then let an agent build your own
|
|
280
310
|
|
|
281
311
|
But lets be honest, who reads documentation these days. Paste this into your
|
|
@@ -359,8 +389,11 @@ noted in steps 2, 3, and 4 below.
|
|
|
359
389
|
users only; model `haiku`, `--model <name>` overrides) and reuses its login.
|
|
360
390
|
With no `--provider`, `gtd judge run` picks jev when `TYPESAFE_API_KEY` is
|
|
361
391
|
set and non-empty and no `--model` is given, else llm — never falling back
|
|
362
|
-
across them;
|
|
363
|
-
|
|
392
|
+
across them; a `.gtdrc` `judge: { provider, model }` key and the
|
|
393
|
+
`GTD_JUDGE_PROVIDER` / `GTD_JUDGE_MODEL` environment variables choose the
|
|
394
|
+
judge without editing the driver (flags win over both); `--model` with
|
|
395
|
+
`--provider fixed`/`jev` is refused. jev and llm exit 1 with nothing on
|
|
396
|
+
stdout when they cannot answer every question):
|
|
364
397
|
- Every red round after the first: was the failure identical, new, or
|
|
365
398
|
progress?
|
|
366
399
|
- Before spending a review turn on a package: does the code already satisfy
|
|
@@ -376,14 +409,16 @@ noted in steps 2, 3, and 4 below.
|
|
|
376
409
|
floors and skip a gate unattended.**
|
|
377
410
|
|
|
378
411
|
Once the last package is built, the whole change goes through a qualitative
|
|
379
|
-
review lap before you see anything:
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
412
|
+
review lap before you see anything: six lenses, one turn each, in order —
|
|
413
|
+
`correctness`, `owasp-security`, `ponytail-review`, `test-audit`,
|
|
414
|
+
`conventions`, `spec-challenge`. Each traces the change from its own angle
|
|
415
|
+
and records every finding, blocking or not; one fix turn then fixes ALL of
|
|
416
|
+
them once, with no re-review after the fix. A clean turn means approval only
|
|
417
|
+
when that lens found nothing at all. The per-package review above only judges
|
|
418
|
+
that package against its own spec; this lap is where code quality is looked
|
|
419
|
+
at, and every round pays for it. It never replaces step 4 — your review stays
|
|
420
|
+
the final gate, and nothing here skips it. The `gtd --entry fix-precheck`
|
|
421
|
+
side door (below) repairs a red baseline through this same lap.
|
|
387
422
|
|
|
388
423
|
A red suite that keeps failing past a few fix attempts escalates instead of
|
|
389
424
|
retrying forever: an agent turn reads the failing output and writes
|
|
@@ -395,20 +430,25 @@ noted in steps 2, 3, and 4 below.
|
|
|
395
430
|
attempt anywhere new.
|
|
396
431
|
|
|
397
432
|
4. **You review.** You get a review document listing what changed and what to
|
|
398
|
-
look at.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
`
|
|
411
|
-
|
|
433
|
+
look at. Before you see it, an automatic risk-fix pass
|
|
434
|
+
(`build.review.fix-risks`) runs. Any risk the reviewer names (a note opening
|
|
435
|
+
with `Risk:`) is fixed first, the suite kept green, and the review rewritten
|
|
436
|
+
— once per review round, so a risk the rewrite still names reaches you
|
|
437
|
+
unfixed; risk: a fix lands with no check that the risk was real. Tick the
|
|
438
|
+
boxes to approve, or write what is wrong. Approving ends the process;
|
|
439
|
+
feedback is judged note by note, each as `edit`, `question`, `nit` or
|
|
440
|
+
`praise`. An `edit` sends the process back to step 2 for a fresh plan — it
|
|
441
|
+
never patches over a design you rejected. A `question` is answered inline
|
|
442
|
+
under your note and the process stops at the review again — no new plan. A
|
|
443
|
+
`nit` is fixed in one batch, the suite must go green (a red one gets fix
|
|
444
|
+
turns first), then a fresh review of the change stops at the review again.
|
|
445
|
+
`praise` is dropped; a round of only praise signs off. When a round mixes
|
|
446
|
+
them, questions are answered and nits fixed first, then the edits are planned
|
|
447
|
+
— risk: the planning lap may redo nit fixes it touches. A hand-edit to code
|
|
448
|
+
always plans a lap. Only a confident non-`edit` verdict skips the replan — a
|
|
449
|
+
note whose evidence was cut, or that got no verdict, counts as `edit`. The
|
|
450
|
+
same `gtd judge answer` / conservative-default shape as step 3's own judged
|
|
451
|
+
points.
|
|
412
452
|
|
|
413
453
|
You never talk to it. Every exchange is a file in `.gtd/` that you edit in your
|
|
414
454
|
own editor, and every answer you give is a commit. Your test suite is the gate
|
package/bin/gtd
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Stop } from "../types"
|
|
2
|
+
|
|
3
|
+
// The subset of `gtd next --json` this driver reads. Absent optional fields
|
|
4
|
+
// are simply missing; booleans may arrive as strings.
|
|
5
|
+
export type Beat = {
|
|
6
|
+
kind: string
|
|
7
|
+
idle?: boolean | string
|
|
8
|
+
state?: string
|
|
9
|
+
label?: string
|
|
10
|
+
file?: string
|
|
11
|
+
content?: string
|
|
12
|
+
log?: string
|
|
13
|
+
memory?: string
|
|
14
|
+
model?: string
|
|
15
|
+
system?: string
|
|
16
|
+
validate?: string
|
|
17
|
+
judge?: unknown
|
|
18
|
+
changes?: { status: string; path: string }[]
|
|
19
|
+
session?: { id?: string; resume?: boolean | string }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Landing = {
|
|
23
|
+
script: string
|
|
24
|
+
settled?: boolean | string
|
|
25
|
+
idle?: boolean | string
|
|
26
|
+
subject?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Turn = {
|
|
30
|
+
memory: string
|
|
31
|
+
resume: boolean
|
|
32
|
+
prompt: string
|
|
33
|
+
label?: string
|
|
34
|
+
model?: string
|
|
35
|
+
system?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type TurnEnd = { ok: true } | { ok: false; why: string }
|
|
39
|
+
|
|
40
|
+
export type Io = {
|
|
41
|
+
next(): Promise<Beat>
|
|
42
|
+
plain(): Promise<string>
|
|
43
|
+
land(verdict?: string): Promise<Landing>
|
|
44
|
+
sh(script: string, log?: string): Promise<number>
|
|
45
|
+
check(script: string): Promise<{ ok: boolean; out: string }>
|
|
46
|
+
judge(): Promise<string | undefined>
|
|
47
|
+
turn(t: Turn): Promise<TurnEnd>
|
|
48
|
+
resume(memory: string, text: string): Promise<TurnEnd>
|
|
49
|
+
progress(beat: number, b: Beat): void
|
|
50
|
+
stopped(): boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const MAX_FIXES = 3
|
|
54
|
+
|
|
55
|
+
export const isTrue = (v: unknown) => v === true || v === "true"
|
|
56
|
+
|
|
57
|
+
// What acting on one beat came to: stop the run, or land the beat (with the
|
|
58
|
+
// verdict when a judge gate was answered).
|
|
59
|
+
type Acted = { stop: Stop } | { stop?: undefined; verdict?: string }
|
|
60
|
+
|
|
61
|
+
const where = (b: Beat) => ({ state: b.state, label: b.label })
|
|
62
|
+
const failed = (b: Beat, text: string): Acted => ({ stop: { kind: "error", text, ...where(b) } })
|
|
63
|
+
|
|
64
|
+
// The reference sh driver from docs/driver.md, beat for beat. gtd decides;
|
|
65
|
+
// this only executes what `gtd next` and `gtd land` print.
|
|
66
|
+
// `landTurn` names a memory scope whose agent turn already ran (a reload cut
|
|
67
|
+
// the loop off while it did): the opening prompt beat lands it, not repeats it.
|
|
68
|
+
export async function drive(io: Io, landTurn?: string): Promise<Stop> {
|
|
69
|
+
// A judge gate answered on the previous beat that rests on the same state
|
|
70
|
+
// again moved nothing: hand it to the human rather than pay for it twice.
|
|
71
|
+
let judged: string | undefined
|
|
72
|
+
for (let beat = 1; ; beat++) {
|
|
73
|
+
if (io.stopped()) return { kind: "stopped", text: "stopped on request" }
|
|
74
|
+
const b = await io.next()
|
|
75
|
+
io.progress(beat, b)
|
|
76
|
+
// The opening beat lands even at idle: a re-run there is the human's
|
|
77
|
+
// decision, and an `acceptClean` first step must get to fire.
|
|
78
|
+
if (beat > 1 && isTrue(b.idle)) return { kind: "done", text: await io.plain(), ...where(b) }
|
|
79
|
+
|
|
80
|
+
const acted = await act(io, b, beat, judged, beat === 1 ? landTurn : undefined)
|
|
81
|
+
if (acted.stop) return acted.stop
|
|
82
|
+
judged = acted.verdict ? b.state : undefined
|
|
83
|
+
const landed = await land(io, b, acted.verdict)
|
|
84
|
+
if (landed) return landed
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function act(
|
|
89
|
+
io: Io,
|
|
90
|
+
b: Beat,
|
|
91
|
+
beat: number,
|
|
92
|
+
judged: string | undefined,
|
|
93
|
+
landTurn: string | undefined,
|
|
94
|
+
): Promise<Acted> {
|
|
95
|
+
switch (b.kind) {
|
|
96
|
+
case "stalled":
|
|
97
|
+
return { stop: { kind: "stalled", text: b.content ?? "", ...where(b) } }
|
|
98
|
+
case "message":
|
|
99
|
+
return message(io, b, beat, judged)
|
|
100
|
+
case "capture":
|
|
101
|
+
return {}
|
|
102
|
+
case "script":
|
|
103
|
+
await io.sh(b.content ?? "", b.log)
|
|
104
|
+
return {}
|
|
105
|
+
case "prompt":
|
|
106
|
+
return agent(io, b, landTurn)
|
|
107
|
+
default:
|
|
108
|
+
return failed(b, `unknown beat kind '${b.kind}'`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function message(io: Io, b: Beat, beat: number, judged: string | undefined): Promise<Acted> {
|
|
113
|
+
const verdict = b.judge && judged !== b.state ? await io.judge() : undefined
|
|
114
|
+
if (verdict || beat === 1) return { verdict }
|
|
115
|
+
// Any later beat is a gate this run produced and the human has not read yet.
|
|
116
|
+
const stop: Stop = { kind: "gate", text: b.content ?? "", ...where(b) }
|
|
117
|
+
if (b.judge) stop.isJudge = true
|
|
118
|
+
if (b.file) stop.file = b.file
|
|
119
|
+
return { stop }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function agent(io: Io, b: Beat, landTurn: string | undefined): Promise<Acted> {
|
|
123
|
+
const memory = b.memory ?? b.session?.id ?? b.state ?? "root"
|
|
124
|
+
const t =
|
|
125
|
+
landTurn === memory
|
|
126
|
+
? ({ ok: true } as const)
|
|
127
|
+
: await io.turn({
|
|
128
|
+
memory,
|
|
129
|
+
resume: isTrue(b.session?.resume),
|
|
130
|
+
prompt: b.content ?? "",
|
|
131
|
+
label: b.label,
|
|
132
|
+
model: b.model || undefined,
|
|
133
|
+
system: b.system || undefined,
|
|
134
|
+
})
|
|
135
|
+
if (!t.ok) return failed(b, t.why)
|
|
136
|
+
return b.validate ? validated(io, b, b.validate, memory) : {}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The step's own validator; its findings go back to the same conversation.
|
|
140
|
+
async function validated(io: Io, b: Beat, validate: string, memory: string): Promise<Acted> {
|
|
141
|
+
for (let fixes = 0; ; fixes++) {
|
|
142
|
+
const v = await io.check(validate)
|
|
143
|
+
if (v.ok) return {}
|
|
144
|
+
if (fixes >= MAX_FIXES)
|
|
145
|
+
return failed(b, `validation still failing after ${fixes} fixes\n${v.out}`)
|
|
146
|
+
const f = await io.resume(memory, v.out)
|
|
147
|
+
if (!f.ok) return failed(b, f.why)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function land(io: Io, b: Beat, verdict: string | undefined): Promise<Stop | undefined> {
|
|
152
|
+
const l = await io.land(verdict)
|
|
153
|
+
const code = await io.sh(l.script, b.log)
|
|
154
|
+
if (code !== 0) return { kind: "error", text: `landing script exited ${code}`, ...where(b) }
|
|
155
|
+
if (!isTrue(l.settled)) return undefined
|
|
156
|
+
const text = isTrue(l.idle) ? await io.plain() : l.subject || "settled"
|
|
157
|
+
return { kind: "done", text, ...where(b) }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// What a reload that cut the loop off does next. Only a turn the agent
|
|
161
|
+
// finished lands; any other end runs it again. A script cut off may have run
|
|
162
|
+
// partly, and running it again is not safe to assume, so a person decides.
|
|
163
|
+
export type Reloaded = "wait" | "land" | "rerun" | "halt"
|
|
164
|
+
|
|
165
|
+
export function afterReload(cut: { isScripting?: boolean; agentStatus?: string }): Reloaded {
|
|
166
|
+
if (cut.isScripting) return "halt"
|
|
167
|
+
if (cut.agentStatus === "running") return "wait"
|
|
168
|
+
return cut.agentStatus === "completed" ? "land" : "rerun"
|
|
169
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The workflow's side doors: `fix` repairs a red baseline as its own reviewed
|
|
2
|
+
// commit, `review` reviews everything since a base, straight to the review
|
|
3
|
+
// tail. `gtd --entry` prints the script that starts the process; running it
|
|
4
|
+
// is the driver's job, as with every git write gtd plans.
|
|
5
|
+
|
|
6
|
+
import type { ShipIo, Shipped } from "./ship"
|
|
7
|
+
|
|
8
|
+
const fail = (text: string): Shipped => ({ ok: false, text })
|
|
9
|
+
|
|
10
|
+
export async function enter(io: ShipIo, door: "fix" | "review", base?: string): Promise<Shipped> {
|
|
11
|
+
const args = door === "fix" ? ["--entry", "fix-precheck"] : await reviewArgs(io, base)
|
|
12
|
+
if (typeof args === "string") return fail(args)
|
|
13
|
+
const planned = await io.run(["gtd", ...args])
|
|
14
|
+
if (planned.code !== 0)
|
|
15
|
+
return fail(planned.err.trim() || `gtd ${args.join(" ")} exited ${planned.code}`)
|
|
16
|
+
const started = await io.run(["sh", "-c", planned.out])
|
|
17
|
+
if (started.code !== 0) return fail(`The entry script failed.\n${started.err.trim()}`)
|
|
18
|
+
return {
|
|
19
|
+
ok: true,
|
|
20
|
+
text: door === "fix" ? "Started a fix." : `Started a review since ${args.at(-1)?.slice(-7)}.`,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Reviews the branch since it left `base`, the default branch unless named.
|
|
25
|
+
async function reviewArgs(io: ShipIo, base: string | undefined) {
|
|
26
|
+
const git = async (...a: string[]) => (await io.run(["git", ...a])).out.trim()
|
|
27
|
+
const from =
|
|
28
|
+
base || (await git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")) || "main"
|
|
29
|
+
const mergeBase = await git("merge-base", from, "HEAD")
|
|
30
|
+
if (!mergeBase) return `There is no common ancestor of ${from} and HEAD to review from.`
|
|
31
|
+
if (mergeBase === (await git("rev-parse", "HEAD")))
|
|
32
|
+
return `Nothing to review: HEAD has no commits beyond ${from}.`
|
|
33
|
+
return ["--entry", "review-gate.check", "--var", `reviewBase=${mergeBase}`]
|
|
34
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Multiplayer gtd: a process is entirely git, so handing it to someone else is
|
|
2
|
+
// pushing the branch and telling them. `throw` opens (or refreshes) a draft
|
|
3
|
+
// pull request for the hand-off; `catch` checks it out wherever it was thrown.
|
|
4
|
+
|
|
5
|
+
import { pushBranch, THROWN } from "./ship"
|
|
6
|
+
import type { ShipIo, Shipped } from "./ship"
|
|
7
|
+
|
|
8
|
+
export type Rest = { state?: string; label?: string; content?: string; isIdle: boolean }
|
|
9
|
+
|
|
10
|
+
const fail = (text: string): Shipped => ({ ok: false, text })
|
|
11
|
+
|
|
12
|
+
type Pr = {
|
|
13
|
+
number: number
|
|
14
|
+
url: string
|
|
15
|
+
state?: string
|
|
16
|
+
body?: string
|
|
17
|
+
assignees?: { login: string }[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function throwTo(
|
|
21
|
+
io: ShipIo,
|
|
22
|
+
rest: Rest,
|
|
23
|
+
target: string | undefined,
|
|
24
|
+
now: string,
|
|
25
|
+
): Promise<Shipped> {
|
|
26
|
+
if (rest.isIdle) return fail("Nothing is in progress, so there is nothing to throw.")
|
|
27
|
+
// Only landed state travels: an edit still in the tree is half an answer.
|
|
28
|
+
if ((await io.run(["git", "status", "--porcelain"])).out.trim()) {
|
|
29
|
+
return fail("The working tree has edits. Proceed to land them, or stash them, then throw.")
|
|
30
|
+
}
|
|
31
|
+
const on = await throwBranch(io, now)
|
|
32
|
+
if (typeof on === "string") return fail(on)
|
|
33
|
+
const behind = await remoteAhead(io, on.branch)
|
|
34
|
+
if (behind) return fail(behind)
|
|
35
|
+
const pushed = await pushBranch(io, on.branch)
|
|
36
|
+
if (pushed) return fail(pushed)
|
|
37
|
+
const at = rest.label ? `${rest.state} (${rest.label})` : (rest.state ?? "a gate")
|
|
38
|
+
const pr = await draft(io, on.branch, on.base, rest, at)
|
|
39
|
+
if (typeof pr === "string") return fail(pr)
|
|
40
|
+
const handle = target?.replace(/^@/, "")
|
|
41
|
+
if (handle) {
|
|
42
|
+
const assigned = await assign(io, pr, handle)
|
|
43
|
+
if (assigned) return fail(assigned)
|
|
44
|
+
}
|
|
45
|
+
const to = handle ? `to @${handle}` : "to anyone"
|
|
46
|
+
await io.run(
|
|
47
|
+
["gh", "pr", "comment", String(pr.number), "--body-file", "-"],
|
|
48
|
+
`Thrown ${to} at **${at}**. Catch it with \`/gtd catch ${on.branch}\`.\n`,
|
|
49
|
+
)
|
|
50
|
+
return { ok: true, text: `Thrown ${to}: ${pr.url}` }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The catcher checks out what origin holds, so a throw describing local HEAD
|
|
54
|
+
// must not leave origin elsewhere: fetch first, a stale tracking ref can hide
|
|
55
|
+
// a teammate's commits or a rewind. Returns why the throw refuses, or nothing.
|
|
56
|
+
async function remoteAhead(io: ShipIo, branch: string) {
|
|
57
|
+
const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
|
|
58
|
+
if (!(await ok("fetch", "--prune", "origin"))) return "git fetch failed."
|
|
59
|
+
if (!(await ok("rev-parse", "--verify", "--quiet", `origin/${branch}`))) return undefined
|
|
60
|
+
if (await ok("merge-base", "--is-ancestor", `origin/${branch}`, "HEAD")) return undefined
|
|
61
|
+
return `origin/${branch} has commits HEAD lacks. Pull or catch them first, then throw.`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The branch to throw, moving the process off the default branch onto a new
|
|
65
|
+
// one when it sits there; a string is why that failed.
|
|
66
|
+
async function throwBranch(io: ShipIo, now: string) {
|
|
67
|
+
const git = async (...args: string[]) => (await io.run(["git", ...args])).out.trim()
|
|
68
|
+
const branch = await git("rev-parse", "--abbrev-ref", "HEAD")
|
|
69
|
+
if (!branch || branch === "HEAD") return "Detached HEAD: there is no branch to throw."
|
|
70
|
+
const head = await git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
|
|
71
|
+
const base = head.replace(/^origin\//, "") || "main"
|
|
72
|
+
if (branch !== base) return { branch, base }
|
|
73
|
+
const fresh = `gtd/${now.replace(/[-:]/g, "").replace("T", "-").slice(0, 13)}`
|
|
74
|
+
const switched = await io.run(["git", "switch", "-c", fresh])
|
|
75
|
+
if (switched.code !== 0) return `Could not create ${fresh}.\n${switched.err}`
|
|
76
|
+
io.log(`Moved the process onto a new branch, ${fresh}.`)
|
|
77
|
+
return { branch: fresh, base }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const throwBody = (branch: string, at: string, content = "") => `${THROWN}
|
|
81
|
+
This gtd process was thrown at **${at}** and waits for whoever catches it.
|
|
82
|
+
|
|
83
|
+
Catch it in Claude Code with the gtd mod:
|
|
84
|
+
|
|
85
|
+
\`\`\`
|
|
86
|
+
/gtd catch ${branch}
|
|
87
|
+
\`\`\`
|
|
88
|
+
|
|
89
|
+
or without it: \`gh pr checkout ${branch} && gtd next\`.
|
|
90
|
+
|
|
91
|
+
<details><summary>What the process is waiting for</summary>
|
|
92
|
+
|
|
93
|
+
${content.slice(0, 6000)}
|
|
94
|
+
|
|
95
|
+
</details>
|
|
96
|
+
`
|
|
97
|
+
|
|
98
|
+
// Opens the hand-off's draft, or refreshes a thrown one; a string is why that
|
|
99
|
+
// failed.
|
|
100
|
+
async function draft(
|
|
101
|
+
io: ShipIo,
|
|
102
|
+
branch: string,
|
|
103
|
+
base: string,
|
|
104
|
+
rest: Rest,
|
|
105
|
+
at: string,
|
|
106
|
+
): Promise<Pr | string> {
|
|
107
|
+
const body = throwBody(branch, at, rest.content)
|
|
108
|
+
const view = await io.run([
|
|
109
|
+
"gh",
|
|
110
|
+
"pr",
|
|
111
|
+
"view",
|
|
112
|
+
branch,
|
|
113
|
+
"--json",
|
|
114
|
+
"number,url,state,body,assignees",
|
|
115
|
+
])
|
|
116
|
+
if (view.code !== 0) {
|
|
117
|
+
const title = `wip: ${branch} — ${rest.label ?? rest.state}`
|
|
118
|
+
const created = await io.run(
|
|
119
|
+
[
|
|
120
|
+
"gh",
|
|
121
|
+
"pr",
|
|
122
|
+
"create",
|
|
123
|
+
"--draft",
|
|
124
|
+
"--head",
|
|
125
|
+
branch,
|
|
126
|
+
"--base",
|
|
127
|
+
base,
|
|
128
|
+
"--title",
|
|
129
|
+
title,
|
|
130
|
+
"--body-file",
|
|
131
|
+
"-",
|
|
132
|
+
],
|
|
133
|
+
body,
|
|
134
|
+
)
|
|
135
|
+
if (created.code !== 0) return `gh pr create failed; nothing was opened.\n${created.err}`
|
|
136
|
+
const url = created.out.trim()
|
|
137
|
+
return { number: Number(url.split("/").pop()), url }
|
|
138
|
+
}
|
|
139
|
+
const pr = JSON.parse(view.out) as Pr
|
|
140
|
+
if (pr.state !== "OPEN") return `The pull request for ${branch} is ${pr.state}, not open.`
|
|
141
|
+
// A pull request someone opened for review keeps its own description and
|
|
142
|
+
// stays ready: only ship marks a draft ready again, and only a thrown one.
|
|
143
|
+
if (pr.body?.includes(THROWN)) {
|
|
144
|
+
await io.run(["gh", "pr", "edit", String(pr.number), "--body-file", "-"], body)
|
|
145
|
+
await io.run(["gh", "pr", "ready", String(pr.number), "--undo"])
|
|
146
|
+
}
|
|
147
|
+
return pr
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function assign(io: ShipIo, pr: Pr, handle: string) {
|
|
151
|
+
const stale = (pr.assignees ?? []).map((a) => a.login).filter((login) => login !== handle)
|
|
152
|
+
const args = ["gh", "pr", "edit", String(pr.number), "--add-assignee", handle]
|
|
153
|
+
for (const login of stale) args.push("--remove-assignee", login)
|
|
154
|
+
const assigned = await io.run(args)
|
|
155
|
+
return assigned.code === 0
|
|
156
|
+
? undefined
|
|
157
|
+
: `Thrown to ${pr.url}, but assigning @${handle} failed.\n${assigned.err}`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function catchFrom(io: ShipIo, ref: string): Promise<Shipped> {
|
|
161
|
+
if ((await io.run(["git", "status", "--porcelain"])).out.trim()) {
|
|
162
|
+
return fail("The working tree has edits. Commit or stash them before catching.")
|
|
163
|
+
}
|
|
164
|
+
const checkout = await io.run(["gh", "pr", "checkout", ref])
|
|
165
|
+
if (checkout.code !== 0) return fail(`Could not check out ${ref}.\n${checkout.err}`)
|
|
166
|
+
const view = await io.run(["gh", "pr", "view", ref, "--json", "number,url"])
|
|
167
|
+
if (view.code === 0) {
|
|
168
|
+
const pr = JSON.parse(view.out) as { number: number; url: string }
|
|
169
|
+
await io.run(["gh", "pr", "edit", String(pr.number), "--add-assignee", "@me"])
|
|
170
|
+
await io.run(["gh", "pr", "comment", String(pr.number), "--body", "Caught."])
|
|
171
|
+
return { ok: true, text: `Caught ${pr.url}` }
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, text: `Checked out ${ref}.` }
|
|
174
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// gtd's `llm` judge provider, answered by a subagent instead of `claude -p`.
|
|
2
|
+
// A port of src/judges/providers/llm.ts (which needs Node, which a mod has
|
|
3
|
+
// not): claude/hooks/judge.test.ts pins the prompt to gtd's own, so the two
|
|
4
|
+
// cannot drift apart unnoticed.
|
|
5
|
+
|
|
6
|
+
type Question = {
|
|
7
|
+
id: string
|
|
8
|
+
primitive: string
|
|
9
|
+
instructions: string
|
|
10
|
+
criteria: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Judgment = { state?: Record<string, string>; questions: Question[] }
|
|
14
|
+
|
|
15
|
+
type Verdict = { id: string; answer: string | number; p: number }
|
|
16
|
+
|
|
17
|
+
export const JUDGE_SYSTEM =
|
|
18
|
+
"You are a strict, impartial judge. Answer each question from the supplied evidence alone.\n" +
|
|
19
|
+
"Reply only with the structured answer; never use tools or ask questions."
|
|
20
|
+
|
|
21
|
+
const LABEL = /(?:^|\. )([A-Za-z][A-Za-z0-9_-]*): /g
|
|
22
|
+
|
|
23
|
+
const splitLabels = (criteria: string): [string, string][] => {
|
|
24
|
+
const hits = [...criteria.matchAll(LABEL)]
|
|
25
|
+
return hits.map((m, i) => {
|
|
26
|
+
const start = m.index + m[0].length
|
|
27
|
+
const end = hits[i + 1]?.index ?? criteria.length
|
|
28
|
+
return [m[1] as string, criteria.slice(start, end).replace(/^ /, "")]
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A string is why the question cannot be put to a model.
|
|
33
|
+
const levelsOf = (q: Question): [string, string][] | string => {
|
|
34
|
+
if (q.primitive === "noul") return []
|
|
35
|
+
if (q.primitive !== "choice" && q.primitive !== "score") {
|
|
36
|
+
return `question "${q.id}": unknown primitive "${q.primitive}"`
|
|
37
|
+
}
|
|
38
|
+
const levels = splitLabels(q.criteria)
|
|
39
|
+
return levels.length < 2
|
|
40
|
+
? `question "${q.id}": ${q.primitive} criteria need at least 2 labelled ${q.primitive === "choice" ? "options" : "levels"}`
|
|
41
|
+
: levels
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const optionsOf = (q: Question, levels: [string, string][]): (string | number)[] =>
|
|
45
|
+
q.primitive === "noul"
|
|
46
|
+
? ["yes", "no"]
|
|
47
|
+
: q.primitive === "choice"
|
|
48
|
+
? levels.map(([label]) => label)
|
|
49
|
+
: levels.map((_, i) => i + 1)
|
|
50
|
+
|
|
51
|
+
const describe = (q: Question, levels: [string, string][]) => {
|
|
52
|
+
const head = `<question id="${q.id}">\n${q.instructions}\n`
|
|
53
|
+
if (q.primitive === "noul")
|
|
54
|
+
return `${head}Criteria: ${q.criteria}\nAnswer "yes" or "no".\n</question>`
|
|
55
|
+
const body =
|
|
56
|
+
q.primitive === "choice"
|
|
57
|
+
? levels.map(([label, text]) => `- ${label}: ${text}`).join("\n")
|
|
58
|
+
: levels.map(([, text], i) => `${i + 1}. ${text}`).join("\n")
|
|
59
|
+
const ask =
|
|
60
|
+
q.primitive === "choice"
|
|
61
|
+
? "Answer with exactly one label."
|
|
62
|
+
: "Answer with the number of one level."
|
|
63
|
+
return `${head}${body}\n${ask}\n</question>`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The prompt gtd's llm provider sends; throws for criteria it cannot ask about.
|
|
67
|
+
export function judgePrompt(j: Judgment): string {
|
|
68
|
+
const parts = [
|
|
69
|
+
"Answer every question below from the evidence alone. For each, give your answer and p, your confidence between 0 and 1 that the given answer is correct.",
|
|
70
|
+
]
|
|
71
|
+
for (const [key, body] of Object.entries(j.state ?? {}))
|
|
72
|
+
parts.push(`<evidence key="${key}">\n${body}\n</evidence>`)
|
|
73
|
+
for (const q of j.questions) {
|
|
74
|
+
const levels = levelsOf(q)
|
|
75
|
+
if (typeof levels === "string") throw new Error(levels)
|
|
76
|
+
parts.push(describe(q, levels))
|
|
77
|
+
}
|
|
78
|
+
return parts.join("\n\n")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The reply `{ <id>: { answer, p } }` as gtd's verdict array, every question
|
|
82
|
+
// answered with one of its own options; a string is why it is not usable.
|
|
83
|
+
export function toVerdicts(j: Judgment, reply: unknown): Verdict[] | string {
|
|
84
|
+
if (typeof reply !== "object" || reply === null) return "the reply is not an object"
|
|
85
|
+
const out: Verdict[] = []
|
|
86
|
+
for (const q of j.questions) {
|
|
87
|
+
const raw = (reply as Record<string, unknown>)[q.id] as
|
|
88
|
+
| { answer?: unknown; p?: unknown }
|
|
89
|
+
| undefined
|
|
90
|
+
const levels = levelsOf(q)
|
|
91
|
+
if (typeof levels === "string") return levels
|
|
92
|
+
if (!raw || typeof raw.p !== "number" || raw.p < 0 || raw.p > 1)
|
|
93
|
+
return `"${q.id}" has no p in [0, 1]`
|
|
94
|
+
if (!optionsOf(q, levels).includes(raw.answer as string | number))
|
|
95
|
+
return `"${q.id}" answer is not one of its options`
|
|
96
|
+
out.push({ id: q.id, answer: raw.answer as string | number, p: raw.p })
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// A workflow names models by role (`smart`, `base`), as hints a driver
|
|
2
|
+
// resolves; the bash driver does it with GTD_PLANNERMODEL / GTD_CODERMODEL.
|
|
3
|
+
// A subagent takes only Claude Code's own aliases, so anything else becomes
|
|
4
|
+
// undefined: the session's own model.
|
|
5
|
+
|
|
6
|
+
const ALIASES = ["sonnet", "opus", "haiku", "fable"] as const
|
|
7
|
+
type Alias = (typeof ALIASES)[number]
|
|
8
|
+
|
|
9
|
+
export type Roles = { smart?: string; base?: string }
|
|
10
|
+
|
|
11
|
+
const asAlias = (name: string): Alias | undefined =>
|
|
12
|
+
ALIASES.find((a) => name === a || name.startsWith(`claude-${a}`))
|
|
13
|
+
|
|
14
|
+
export function subagentModel(hint: string | undefined, roles: Roles): Alias | undefined {
|
|
15
|
+
if (!hint) return undefined
|
|
16
|
+
const resolved =
|
|
17
|
+
hint === "smart" ? (roles.smart ?? "opus") : hint === "base" ? (roles.base ?? "sonnet") : hint
|
|
18
|
+
return asAlias(resolved)
|
|
19
|
+
}
|