@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.
Files changed (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +37 -10
  3. package/claude/hooks/access.ts +25 -0
  4. package/claude/hooks/assist.ts +135 -0
  5. package/claude/hooks/drive.ts +15 -7
  6. package/claude/hooks/judge.ts +27 -0
  7. package/claude/hooks/register.tsx +294 -135
  8. package/claude/hooks/tools.ts +121 -0
  9. package/claude/hooks/wording.ts +2 -35
  10. package/claude/types/index.d.ts +9 -0
  11. package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
  12. package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-DJwU5C4z.mjs} +1 -1
  13. package/dist/gtd.bundle.mjs +337 -55
  14. package/dist/testkit.mjs +477 -65
  15. package/dist/workflows/feature.mjs +1 -1
  16. package/dist/workflows/fix.mjs +1 -1
  17. package/dist/workflows/review.mjs +1 -1
  18. package/package.json +1 -1
  19. package/src/cli/Cli.test.ts +57 -0
  20. package/src/cli/Cli.ts +89 -9
  21. package/src/cli/History.test.ts +225 -0
  22. package/src/cli/History.ts +143 -0
  23. package/src/cli/Output.ts +68 -0
  24. package/src/cli/Program.test.ts +7 -1
  25. package/src/cli/Program.ts +23 -64
  26. package/src/edge/Edge.test.ts +256 -1
  27. package/src/edge/Edge.ts +217 -39
  28. package/src/edge/index.ts +1 -0
  29. package/src/emit/GitScript.test.ts +27 -0
  30. package/src/emit/GitScript.ts +18 -2
  31. package/src/emit/index.ts +1 -0
  32. package/src/platform/Git.ts +48 -10
  33. package/src/platform/RetainedHistory.test.ts +14 -0
  34. package/src/platform/RetainedHistory.ts +5 -0
  35. package/src/platform/index.ts +7 -1
  36. package/src/replay/Trailers.test.ts +32 -0
  37. package/src/replay/Trailers.ts +17 -0
  38. package/src/step/LandStep.ts +3 -1
  39. package/src/step/RepoSnapshot.ts +7 -1
  40. package/src/step/planStart.test.ts +6 -3
  41. package/src/step/planStep.test.ts +29 -1
  42. package/src/step/planStep.ts +8 -0
  43. package/src/testkit/EmittedScriptRecognizer.ts +1 -1
  44. package/src/testkit/FakeGitOperations.ts +6 -2
  45. package/src/testkit/GitTiers.ts +133 -2
  46. package/src/testkit/InMemRepo.ts +26 -6
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "24.1.0",
3
+ "version": "24.3.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
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. Every
333
- human rest opens Claude Code's own question dialog, with a link to `gtd ui` for
334
- the step; review there, then answer **I'm done, continue**. When the process
335
- finishes, **Yes, open the pull request** (or `/gtd ship`) squashes it into one
336
- commit and opens it. A process can change hands at any gate: **Hand off to
337
- someone else** (or `/gtd throw @dev`) opens a draft pull request assigned to
338
- them, and `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote
339
- the requirements can hand the architecture to someone else. See
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`, and the tools `ui`, `lsp`, `loop` and `judge` (`judge answer`,
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
@@ -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
+ }
@@ -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
- // `landTurn` names a memory scope whose agent turn already ran (a reload cut
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 ? landTurn : undefined)
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
- landTurn: string | undefined,
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 message(io, b, beat, judged)
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
 
@@ -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
+ }