@pmelab/gtd 24.1.0 → 24.3.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 +37 -10
- package/claude/hooks/access.ts +25 -0
- package/claude/hooks/assist.ts +135 -0
- package/claude/hooks/drive.ts +15 -7
- package/claude/hooks/judge.ts +27 -0
- package/claude/hooks/register.tsx +294 -135
- package/claude/hooks/tools.ts +121 -0
- package/claude/hooks/wording.ts +2 -35
- package/claude/types/index.d.ts +9 -0
- package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
- package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-DJwU5C4z.mjs} +1 -1
- package/dist/gtd.bundle.mjs +337 -55
- package/dist/testkit.mjs +477 -65
- package/dist/workflows/feature.mjs +1 -1
- package/dist/workflows/fix.mjs +1 -1
- package/dist/workflows/review.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +57 -0
- package/src/cli/Cli.ts +89 -9
- package/src/cli/History.test.ts +225 -0
- package/src/cli/History.ts +143 -0
- package/src/cli/Output.ts +68 -0
- package/src/cli/Program.test.ts +7 -1
- package/src/cli/Program.ts +23 -64
- package/src/edge/Edge.test.ts +256 -1
- package/src/edge/Edge.ts +217 -39
- package/src/edge/index.ts +1 -0
- package/src/emit/GitScript.test.ts +27 -0
- package/src/emit/GitScript.ts +18 -2
- package/src/emit/index.ts +1 -0
- package/src/platform/Git.ts +48 -10
- package/src/platform/RetainedHistory.test.ts +14 -0
- package/src/platform/RetainedHistory.ts +5 -0
- package/src/platform/index.ts +7 -1
- package/src/replay/Trailers.test.ts +32 -0
- package/src/replay/Trailers.ts +17 -0
- package/src/step/LandStep.ts +3 -1
- package/src/step/RepoSnapshot.ts +7 -1
- package/src/step/planStart.test.ts +6 -3
- package/src/step/planStep.test.ts +29 -1
- package/src/step/planStep.ts +8 -0
- package/src/testkit/EmittedScriptRecognizer.ts +1 -1
- package/src/testkit/FakeGitOperations.ts +6 -2
- package/src/testkit/GitTiers.ts +133 -2
- package/src/testkit/InMemRepo.ts +26 -6
package/README.md
CHANGED
|
@@ -329,14 +329,17 @@ Then, in any repository:
|
|
|
329
329
|
```
|
|
330
330
|
|
|
331
331
|
That starts a process from your requirements and drives it until it needs you.
|
|
332
|
-
`/gtd fix` and `/gtd review [base]` take the two doors described below.
|
|
333
|
-
human rest
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
332
|
+
`/gtd fix` and `/gtd review [base]` take the two doors described below. At every
|
|
333
|
+
human rest Claude itself takes over: it walks you through the open questions one
|
|
334
|
+
at a time, answers your own questions about the plan or the diff, records what
|
|
335
|
+
you decide in the steering file the way the step asks, and moves the process on
|
|
336
|
+
only when you say so. While a step runs you can keep asking; requests for
|
|
337
|
+
changes wait for the next rest. When the process finishes, Claude offers to
|
|
338
|
+
squash it into one commit and open its pull request (or run `/gtd ship`). A
|
|
339
|
+
process can change hands at any gate: ask Claude to hand it off (or run
|
|
340
|
+
`/gtd throw @dev`) and it opens a draft pull request assigned to them, and
|
|
341
|
+
`/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
|
|
342
|
+
requirements can hand the architecture to someone else. See
|
|
340
343
|
[Inside Claude Code](https://github.com/pmelab/gtd/blob/main/docs/driver.md#inside-claude-code-the-gtd-mod).
|
|
341
344
|
|
|
342
345
|
### Then let an agent build your own
|
|
@@ -541,6 +544,30 @@ unknown key. `gtd.config.ts` is the only repository config: gtd 23 removed the
|
|
|
541
544
|
home directory — fails every command until you delete it. See
|
|
542
545
|
[Migrating from `.gtdrc`](https://github.com/pmelab/gtd/blob/main/docs/configuration.md#migrating-from-gtdrc).
|
|
543
546
|
|
|
547
|
+
## Process history
|
|
548
|
+
|
|
549
|
+
When a process finishes, the landing that closes it also keeps the whole run
|
|
550
|
+
behind a ref, `refs/gtd/history/<entry>`, so a squash or a deleted branch
|
|
551
|
+
doesn't lose it. `/gtd ship` changes nothing about this: the turns are already
|
|
552
|
+
kept before it squashes.
|
|
553
|
+
|
|
554
|
+
```sh
|
|
555
|
+
gtd history list # one line per kept process
|
|
556
|
+
gtd history show <ref> # replay its steps and judge verdicts
|
|
557
|
+
gtd history fetch | sh # pull every kept ref from origin
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
`gtd history fetch` only prints a fetch script; pipe it into `sh` to run it.
|
|
561
|
+
Refs are not pushed or fetched by default. To share them:
|
|
562
|
+
|
|
563
|
+
```sh
|
|
564
|
+
git push origin 'refs/gtd/history/*'
|
|
565
|
+
git config --add remote.origin.fetch 'refs/gtd/history/*:refs/gtd/history/*'
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
The second line makes every plain `git fetch` include them. It has no leading
|
|
569
|
+
`+`, so a conflicting local ref fails instead of being overwritten.
|
|
570
|
+
|
|
544
571
|
## Entrypoints
|
|
545
572
|
|
|
546
573
|
gtd is one package, `@pmelab/gtd`, and every entrypoint shares its version and
|
|
@@ -562,8 +589,8 @@ install for any of these but `./testkit`: gtd resolves a `gtd.config.ts`'s
|
|
|
562
589
|
|
|
563
590
|
The `gtd` bin's commands — `next`, `land`, `abandon`, `restore`, `validate`,
|
|
564
591
|
`check`, `uncheck`, `install`, `summary`, `base`, `door`, `doors`, `version`,
|
|
565
|
-
`help`,
|
|
566
|
-
`judge run`) — are listed in the
|
|
592
|
+
`help`, `history list`, `history show`, `history fetch`, and the tools `ui`,
|
|
593
|
+
`lsp`, `loop` and `judge` (`judge answer`, `judge run`) — are listed in the
|
|
567
594
|
[CLI reference](https://github.com/pmelab/gtd/blob/main/docs/cli.md).
|
|
568
595
|
|
|
569
596
|
## License
|
package/claude/hooks/access.ts
CHANGED
|
@@ -63,3 +63,28 @@ function searchDenial(raw: unknown, globs: string[] | null, root: string) {
|
|
|
63
63
|
})
|
|
64
64
|
return inside ? undefined : deny("read", rel || ".", globs)
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
// What the main session may write while gtd drives: nothing while a step runs
|
|
68
|
+
// (gtd commits the tree when it ends), only the steering file at a rest whose
|
|
69
|
+
// mode is `qa`, anything otherwise. A review rest takes code edits as feedback.
|
|
70
|
+
export type MainPhase = { isRunning: boolean; mode?: string; file?: string }
|
|
71
|
+
|
|
72
|
+
export const RUNNING_DENIAL =
|
|
73
|
+
"gtd is running a step and commits the working tree when it ends: don't edit files now. Answer read-only, or record the request with mcp__gtd__note for the next step that waits on the person. /gtd stop stops the run."
|
|
74
|
+
|
|
75
|
+
export function mainDenial(
|
|
76
|
+
tool: string,
|
|
77
|
+
input: Record<string, unknown>,
|
|
78
|
+
phase: MainPhase,
|
|
79
|
+
root: string,
|
|
80
|
+
): string | undefined {
|
|
81
|
+
const key = WRITE[tool]
|
|
82
|
+
if (!key) return undefined
|
|
83
|
+
if (phase.isRunning) return RUNNING_DENIAL
|
|
84
|
+
if (phase.mode !== "qa" || !phase.file) return undefined
|
|
85
|
+
const raw = input[key]
|
|
86
|
+
const rel = typeof raw === "string" ? inRepo(raw, root) : undefined
|
|
87
|
+
return rel === phase.file
|
|
88
|
+
? undefined
|
|
89
|
+
: `At this step only ${phase.file} may change: record the request there, as the step's instructions say.`
|
|
90
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// What the main session's model is told when gtd stops for the person. gtd
|
|
2
|
+
// decides every step; the model only helps the person do what the rest asks,
|
|
3
|
+
// through the rest's own instructions, and moves on through the mod's tools.
|
|
4
|
+
// Workflow-agnostic on purpose: the rest's message is the protocol.
|
|
5
|
+
|
|
6
|
+
import type { Stop } from "../types"
|
|
7
|
+
import { RELOAD_CUT_SCRIPT } from "./wording"
|
|
8
|
+
import { TOOL } from "./tools"
|
|
9
|
+
|
|
10
|
+
export type Asked = {
|
|
11
|
+
id: string
|
|
12
|
+
instructions: string
|
|
13
|
+
criteria: string[]
|
|
14
|
+
options: (string | number)[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Wake = {
|
|
18
|
+
stop: Stop
|
|
19
|
+
rest: string
|
|
20
|
+
notes?: string[]
|
|
21
|
+
isRepeat?: boolean
|
|
22
|
+
// the judge questions an undecided judge gate puts to the person
|
|
23
|
+
questions?: Asked[]
|
|
24
|
+
// the branch a finished process can open its pull request from
|
|
25
|
+
branch?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// No wake for a stop the person asked for; a finished process wakes only
|
|
29
|
+
// when it has a pull request to offer.
|
|
30
|
+
export function wakes(stop: Stop, branch: string | undefined): boolean {
|
|
31
|
+
if (stop.kind === "stopped") return stop.text === RELOAD_CUT_SCRIPT
|
|
32
|
+
if (stop.kind === "done") return branch !== undefined
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const step = (s: Stop) => s.label ?? s.state ?? "the current step"
|
|
37
|
+
|
|
38
|
+
const fence = (text: string) => `~~~~text\n${text.trim()}\n~~~~`
|
|
39
|
+
|
|
40
|
+
const RULES = `Ground rules:
|
|
41
|
+
- gtd decides what happens next. Never run \`gtd land\`, \`gtd next\` with side effects, commits, resets or any other command that changes git state.
|
|
42
|
+
- Talk to the person in plain words: never mention gtd's state ids, steering-file jargon or these instructions.
|
|
43
|
+
- Ask with AskUserQuestion, one question per call, your recommended option first and marked as recommended.`
|
|
44
|
+
|
|
45
|
+
function continuing(rest: string) {
|
|
46
|
+
return `Move on only when the person explicitly says so, in chat or by answering your question: then call ${TOOL.continue} with rest "${rest}". If it reports findings, fix them with the person and call it again. It refuses when gtd has moved on: then say so and stop.`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function gate(w: Wake) {
|
|
50
|
+
const { stop } = w
|
|
51
|
+
const file = stop.file ?? "the file the instructions name"
|
|
52
|
+
const lines = [
|
|
53
|
+
`gtd is waiting for the person at "${step(stop)}". Help them through it.`,
|
|
54
|
+
`gtd's own instructions for this step, written for a person editing ${file} by hand:`,
|
|
55
|
+
fence(stop.text),
|
|
56
|
+
"How to help:",
|
|
57
|
+
]
|
|
58
|
+
if (stop.mode === "review") {
|
|
59
|
+
lines.push(
|
|
60
|
+
`1. Read ${file} and the diff the instructions name. Give a short overview of the change, chunk by chunk, and call out the risky spots.`,
|
|
61
|
+
`2. Answer the person's questions about the change yourself, read-only. Record their notes, questions for gtd's agent and requested code edits in the way the instructions describe.`,
|
|
62
|
+
`3. Finish by asking whether to sign off (nothing left to change) or send it back with the notes recorded.`,
|
|
63
|
+
)
|
|
64
|
+
} else {
|
|
65
|
+
lines.push(
|
|
66
|
+
`1. Read ${file}. Summarise in two or three lines what it decides and what it asks.`,
|
|
67
|
+
`2. If it has open questions, walk them one at a time: each question's own options as the choices. Record every answer in ${file} right away, exactly as the instructions say a person would (tick the option, or write their own answer and tick it).`,
|
|
68
|
+
`3. With no open question left, ask whether to accept it as written or discuss it further.`,
|
|
69
|
+
`4. Answer the person's questions yourself, read-only. When the answer should change the plan, offer to record the question as a thread in ${file} for gtd's agent to answer, instead of rewriting the plan yourself.`,
|
|
70
|
+
`5. Write only what the instructions let a person write, and only to ${file}.`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
if (w.isRepeat) {
|
|
74
|
+
lines.push(
|
|
75
|
+
"The person continued without changing anything and gtd stopped at this same step again: tell them what it still needs.",
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
lines.push(continuing(w.rest))
|
|
79
|
+
return lines
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function judged(w: Wake) {
|
|
83
|
+
const lines = [
|
|
84
|
+
`gtd could not decide "${step(w.stop)}" on its own and needs the person's judgment.`,
|
|
85
|
+
"What gtd says about this step:",
|
|
86
|
+
fence(w.stop.text),
|
|
87
|
+
]
|
|
88
|
+
for (const q of w.questions ?? []) {
|
|
89
|
+
lines.push(
|
|
90
|
+
`Question "${q.id}": ${q.instructions}\n${q.criteria.map((c) => ` - ${c}`).join("\n")}\n Answers: ${q.options.map((o) => JSON.stringify(o)).join(", ")}`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
lines.push(
|
|
94
|
+
"Read whatever the questions are about, then put each one to the person, one at a time, with its answers as the choices and your own reading first.",
|
|
95
|
+
`Once all are answered and the person agrees, call ${TOOL.continue} with rest "${w.rest}" and verdicts mapping each question id to the chosen answer, exactly as listed. If the person would rather not decide, call it without verdicts: gtd then takes the cautious route.`,
|
|
96
|
+
)
|
|
97
|
+
return lines
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function trouble(w: Wake) {
|
|
101
|
+
const { stop } = w
|
|
102
|
+
const why = stop.kind === "stalled" ? "is stuck" : "stopped on a problem"
|
|
103
|
+
return [
|
|
104
|
+
`gtd ${why} at "${step(stop)}":`,
|
|
105
|
+
fence(stop.text),
|
|
106
|
+
"Find the cause (read the error, the log it names, the working tree) and explain it in plain words. Propose a fix and make it only with the person's consent.",
|
|
107
|
+
continuing(w.rest),
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function finished(w: Wake) {
|
|
112
|
+
return [
|
|
113
|
+
`gtd finished the process on branch ${w.branch}.`,
|
|
114
|
+
`Tell the person in two or three lines what was built (read the branch's commits), then ask whether to combine it into one commit and open its pull request. On yes, call ${TOOL.ship}.`,
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function wakePrompt(w: Wake): string {
|
|
119
|
+
const { stop } = w
|
|
120
|
+
const body =
|
|
121
|
+
stop.kind === "done"
|
|
122
|
+
? finished(w)
|
|
123
|
+
: stop.kind === "gate"
|
|
124
|
+
? stop.isJudge
|
|
125
|
+
? judged(w)
|
|
126
|
+
: gate(w)
|
|
127
|
+
: trouble(w)
|
|
128
|
+
const notes = w.notes?.length
|
|
129
|
+
? [
|
|
130
|
+
`While gtd ran, the person asked for the following. Fold each in now in the way this step allows, or tell them why it does not fit here:\n${w.notes.map((n) => `- ${n}`).join("\n")}`,
|
|
131
|
+
]
|
|
132
|
+
: []
|
|
133
|
+
const extras = `Only when the person asks: ${TOOL.handoff} hands the process to someone else, ${TOOL.browser} opens gtd's browser view of this step.`
|
|
134
|
+
return [...body, ...notes, RULES, extras].join("\n\n")
|
|
135
|
+
}
|
package/claude/hooks/drive.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type Beat = {
|
|
|
11
11
|
state?: string
|
|
12
12
|
label?: string
|
|
13
13
|
file?: string
|
|
14
|
+
mode?: string
|
|
14
15
|
content?: string
|
|
15
16
|
log?: string
|
|
16
17
|
memory?: string
|
|
@@ -69,11 +70,15 @@ type Acted = { stop: Stop } | { stop?: undefined; verdict?: string }
|
|
|
69
70
|
const where = (b: Beat) => ({ state: b.state, label: b.label })
|
|
70
71
|
const failed = (b: Beat, text: string): Acted => ({ stop: { kind: "error", text, ...where(b) } })
|
|
71
72
|
|
|
73
|
+
// How the opening beat is taken. `landTurn` names a memory scope whose agent
|
|
74
|
+
// turn already ran (a reload cut the loop off while it did): the opening prompt
|
|
75
|
+
// beat lands it, not repeats it. `isAnswered` is a person's answer to an opening
|
|
76
|
+
// judge gate: their `verdict`, or none to land it untouched (the cautious default).
|
|
77
|
+
export type Opening = { landTurn?: string; isAnswered?: boolean; verdict?: string }
|
|
78
|
+
|
|
72
79
|
// The reference sh driver from docs/driver.md, beat for beat. gtd decides;
|
|
73
80
|
// this only executes what `gtd next` and `gtd land` print.
|
|
74
|
-
|
|
75
|
-
// the loop off while it did): the opening prompt beat lands it, not repeats it.
|
|
76
|
-
export async function drive(io: Io, landTurn?: string): Promise<Stop> {
|
|
81
|
+
export async function drive(io: Io, opening: Opening = {}): Promise<Stop> {
|
|
77
82
|
// A judge gate answered on the previous beat that rests on the same state
|
|
78
83
|
// again moved nothing: hand it to the human rather than pay for it twice.
|
|
79
84
|
let judged: string | undefined
|
|
@@ -85,7 +90,7 @@ export async function drive(io: Io, landTurn?: string): Promise<Stop> {
|
|
|
85
90
|
// decision, and an `acceptClean` first step must get to fire.
|
|
86
91
|
if (beat > 1 && isTrue(b.idle)) return { kind: "done", text: await io.plain(), ...where(b) }
|
|
87
92
|
|
|
88
|
-
const acted = await act(io, b, beat, judged, beat === 1 ?
|
|
93
|
+
const acted = await act(io, b, beat, judged, beat === 1 ? opening : {})
|
|
89
94
|
if (acted.stop) return acted.stop
|
|
90
95
|
judged = acted.verdict ? b.state : undefined
|
|
91
96
|
const landed = await land(io, b, acted.verdict, true)
|
|
@@ -98,20 +103,22 @@ async function act(
|
|
|
98
103
|
b: Beat,
|
|
99
104
|
beat: number,
|
|
100
105
|
judged: string | undefined,
|
|
101
|
-
|
|
106
|
+
opening: Opening,
|
|
102
107
|
): Promise<Acted> {
|
|
103
108
|
switch (b.kind) {
|
|
104
109
|
case "stalled":
|
|
105
110
|
return { stop: { kind: "stalled", text: b.content ?? "", ...where(b) } }
|
|
106
111
|
case "message":
|
|
107
|
-
return
|
|
112
|
+
return opening.isAnswered && b.judge
|
|
113
|
+
? { verdict: opening.verdict }
|
|
114
|
+
: message(io, b, beat, judged)
|
|
108
115
|
case "capture":
|
|
109
116
|
return {}
|
|
110
117
|
case "script":
|
|
111
118
|
await io.sh(b.content ?? "", b.log)
|
|
112
119
|
return {}
|
|
113
120
|
case "prompt":
|
|
114
|
-
return agent(io, b, landTurn)
|
|
121
|
+
return agent(io, b, opening.landTurn)
|
|
115
122
|
default:
|
|
116
123
|
return failed(b, `unknown beat kind '${b.kind}'`)
|
|
117
124
|
}
|
|
@@ -126,6 +133,7 @@ async function message(io: Io, b: Beat, beat: number, judged: string | undefined
|
|
|
126
133
|
const stop: Stop = { kind: "gate", text: b.content ?? "", ...where(b) }
|
|
127
134
|
if (b.judge) stop.isJudge = true
|
|
128
135
|
if (b.file) stop.file = b.file
|
|
136
|
+
if (b.mode) stop.mode = b.mode
|
|
129
137
|
return { stop }
|
|
130
138
|
}
|
|
131
139
|
|
package/claude/hooks/judge.ts
CHANGED
|
@@ -111,3 +111,30 @@ export function toVerdicts(j: Judgment, reply: unknown): Verdict[] | string {
|
|
|
111
111
|
}
|
|
112
112
|
return out
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
// Each question as a person is asked it: its instructions, criteria and the
|
|
116
|
+
// answers it takes; a string is why it cannot be asked.
|
|
117
|
+
export function judgeQuestions(j: Judgment) {
|
|
118
|
+
return j.questions.map((q) => {
|
|
119
|
+
const levels = levelsOf(q)
|
|
120
|
+
if (typeof levels === "string") return levels
|
|
121
|
+
const meaning = levels.map(([label, text], i) =>
|
|
122
|
+
q.primitive === "score" ? `${i + 1}: ${text}` : `${label}: ${text}`,
|
|
123
|
+
)
|
|
124
|
+
return {
|
|
125
|
+
id: q.id,
|
|
126
|
+
instructions: q.instructions,
|
|
127
|
+
criteria: q.primitive === "noul" ? [q.criteria] : meaning,
|
|
128
|
+
options: optionsOf(q, levels),
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A person's `{ <id>: <answer> }` as gtd's verdicts, held with certainty.
|
|
134
|
+
export function humanVerdicts(j: Judgment, answers: unknown): Verdict[] | string {
|
|
135
|
+
if (typeof answers !== "object" || answers === null) return "verdicts must be an object"
|
|
136
|
+
const reply = Object.fromEntries(
|
|
137
|
+
Object.entries(answers).map(([id, answer]) => [id, { answer, p: 1 }]),
|
|
138
|
+
)
|
|
139
|
+
return toVerdicts(j, reply)
|
|
140
|
+
}
|