@pmelab/gtd 27.9.0 → 28.1.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 (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +53 -27
  3. package/claude/hooks/doors.ts +12 -0
  4. package/claude/hooks/register.tsx +2 -4
  5. package/claude/hooks/ship.ts +115 -20
  6. package/dist/chunks/{reserved-UdSuHDUJ.mjs → reserved-DeBS4Ymh.mjs} +178 -32
  7. package/dist/chunks/{tail-fj60rQ3y.mjs → tail-B2SER2Ih.mjs} +1 -1
  8. package/dist/gtd.bundle.mjs +209 -72
  9. package/dist/testkit.mjs +209 -72
  10. package/dist/workflows/cleanup.mjs +3 -4
  11. package/dist/workflows/feature.mjs +2 -2
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +2 -2
  14. package/dist/workflows/rollout.mjs +19 -4
  15. package/dist/workflows.mjs +196 -132
  16. package/package.json +1 -1
  17. package/skills/authoring/SKILL.md +4 -3
  18. package/src/cli/Cli.test.ts +39 -0
  19. package/src/cli/Cli.ts +41 -9
  20. package/src/cli/Program.test.ts +31 -0
  21. package/src/cli/Program.ts +14 -7
  22. package/src/edge/Edge.test.ts +259 -3
  23. package/src/edge/Edge.ts +175 -58
  24. package/src/flows/runtime.ts +20 -1
  25. package/src/install/ConfigScaffold.ts +4 -1
  26. package/src/install/Install.test.ts +10 -0
  27. package/src/install/Install.ts +5 -2
  28. package/src/workflow/load.test.ts +82 -5
  29. package/src/workflow/load.ts +40 -16
  30. package/src/workflows/cleanup/cleanup.feature +21 -0
  31. package/src/workflows/cleanup/cleanup.ts +1 -1
  32. package/src/workflows/index.ts +2 -1
  33. package/src/workflows/rollout/rollout.feature +282 -2
  34. package/src/workflows/rollout/rollout.test.ts +96 -2
  35. package/src/workflows/rollout/rollout.ts +41 -3
  36. package/src/workflows/shared/options.test.ts +25 -2
  37. package/src/workflows/shared/options.ts +7 -4
  38. package/src/workflows/shared/registry.test.ts +41 -1
  39. package/src/workflows/shared/registry.ts +26 -1
  40. package/src/workflows/shared/text.test.ts +377 -2
  41. package/src/workflows/shared/text.ts +65 -7
  42. package/src/workflows/shared/vars.ts +2 -0
  43. package/dist/chunks/registry-CP3YuV9j.mjs +0 -101
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "27.9.0",
3
+ "version": "28.1.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
@@ -327,23 +327,25 @@ Then, in any repository:
327
327
  That starts a process from your requirements and drives it until it needs you. A
328
328
  long, multi-line requirements paste still starts a process; if Claude Code never
329
329
  expanded the paste, gtd says it could not read it, and you write the
330
- requirements to `.gtd/TODO.md` and run `/gtd`. `/gtd fix` and
331
- `/gtd review [base]` take the two doors described below. At every human rest
332
- Claude itself takes over: it walks you through the open questions one at a time,
333
- answers your own questions about the plan or the diff, records what you decide
334
- in the steering file the way the step asks, and moves the process on only when
335
- you say so; a review you leave untouched signs off. While a step runs you can
336
- keep asking; requests for changes wait for the next rest. When the process
337
- finishes, Claude offers to squash it into one commit and open its pull request
338
- (or run `/gtd ship`). A process can change hands at any gate: ask Claude to hand
339
- it off (or run `/gtd throw @dev`) and it opens a draft pull request assigned to
340
- them, and `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote
341
- the requirements can hand the architecture to someone else. Ship, hand-off and
342
- throw answer at once and run in the background, with progress on the status
343
- line; they report by waking Claude, a toast, a transcript line and a push
344
- notification, and a failed push or `gh` call reports as a failure. While a ship
345
- or hand-off runs or is queued, `/gtd catch`, the doors, `/gtd <requirements>`
346
- and bare `/gtd` wait for its report. See
330
+ requirements to `.gtd/TODO.md` and run `/gtd`. `/gtd fix`, `/gtd review [base]`,
331
+ `/gtd rollout <flag>` and `/gtd cleanup <flag>` (where the config exports them)
332
+ take the doors described below; text whose word count does not fit a door's args
333
+ (bare `/gtd rollout` included) starts a process from that text as requirements.
334
+ At every human rest Claude itself takes over: it walks you through the open
335
+ questions one at a time, answers your own questions about the plan or the diff,
336
+ records what you decide in the steering file the way the step asks, and moves
337
+ the process on only when you say so; a review you leave untouched signs off.
338
+ While a step runs you can keep asking; requests for changes wait for the next
339
+ rest. When the process finishes, Claude offers to squash it into one commit and
340
+ open its pull request (or run `/gtd ship`). A process can change hands at any
341
+ gate: ask Claude to hand it off (or run `/gtd throw @dev`) and it opens a draft
342
+ pull request assigned to them, and `/gtd catch <pr>` picks it up exactly where
343
+ it waits, so whoever wrote the requirements can hand the architecture to someone
344
+ else. Ship, hand-off and throw answer at once and run in the background, with
345
+ progress on the status line; they report by waking Claude, a toast, a transcript
346
+ line and a push notification, and a failed push or `gh` call reports as a
347
+ failure. While a ship or hand-off runs or is queued, `/gtd catch`, the doors,
348
+ `/gtd <requirements>` and bare `/gtd` wait for its report. See
347
349
  [Inside Claude Code](https://github.com/pmelab/gtd/blob/main/docs/driver.md#inside-claude-code-the-gtd-mod).
348
350
 
349
351
  ### Then let an agent build your own
@@ -362,8 +364,9 @@ state name to choose. The four commands: **`gtd-build`** drives beats until the
362
364
  process rests; **`gtd-edit`** opens the steering file the process is waiting on
363
365
  right now — falling back to `.gtd/TODO.md` when the resting state declares none;
364
366
  **`gtd-door <name> [args...]`** starts a process through a door (`gtd-door fix`,
365
- `gtd-door review [base]`) and drives it. The `.gtd/TODO.md` fallback is also how
366
- you begin: on a clean repository the edit command opens the empty
367
+ `gtd-door review [base]`, `gtd-door rollout <flag>`, `gtd-door cleanup <flag>`
368
+ where the config exports them) and drives it. The `.gtd/TODO.md` fallback is
369
+ also how you begin: on a clean repository the edit command opens the empty
367
370
  `.gtd/TODO.md`, and whatever you write there is the first sketch the whole
368
371
  process gets planned from. As a final step, if it finds an LSP-capable editor,
369
372
  the briefing also offers to wire up live diagnostics and review actions in it —
@@ -580,11 +583,25 @@ gate.
580
583
 
581
584
  ### Rolling out and cleaning up release flags
582
585
 
583
- Two more bundled workflows retire a release flag:
584
- `gtd --workflow rollout --var flag=<name>` turns it default-on,
585
- `gtd --workflow cleanup --var flag=<name>` removes it afterwards. See
586
+ Two more bundled workflows retire a release flag. They are opt-in: the
587
+ `gtd.config.ts` that `gtd install` writes does not export them, so add `rollout`
588
+ from `@pmelab/gtd/workflows/rollout` and `cleanup` from
589
+ `@pmelab/gtd/workflows/cleanup` to its exports first. `gtd door rollout <flag>`
590
+ turns it default-on, `gtd door cleanup <flag>` removes it afterwards
591
+ (`/gtd rollout <flag>` and `/gtd cleanup <flag>` in Claude Code;
592
+ `gtd --workflow rollout --var flag=<name>` works too). See
586
593
  [Feature flags](docs/configuration.md#rolling-out-and-cleaning-up-a-release-flag).
587
594
 
595
+ Rollout makes "on under `GTD_FLAG_<NAME>=1`" prose wrong until cleanup, so once
596
+ the suite passes it lists every line outside the registry that names the flag
597
+ (as `path:line`, the first 50, then a `git grep` command over tracked files for
598
+ the rest) and edits none of them, leaving no file behind. The squash carries the
599
+ list only through the closing message `gtd summary` writes; the `Gtd-Mark:`
600
+ trailers survive only in the process history ref `refs/gtd/history/<entry>`,
601
+ recovered with `gtd history fetch | sh` and read with
602
+ `git log --format=%B <ref>`. An empty list says so in one line. Fix the prose by
603
+ hand or leave it for cleanup.
604
+
588
605
  The delivery model these workflows implement: [Delivery model](SDLC.md).
589
606
 
590
607
  ## Checking a range for stray files
@@ -604,8 +621,11 @@ or `.gitmodules` `ignore` say. Works in any git repository, no process needed.
604
621
 
605
622
  When a process finishes, the landing that closes it also keeps the whole run
606
623
  behind a ref, `refs/gtd/history/<entry>`, so a squash or a deleted branch
607
- doesn't lose it. `/gtd ship` changes nothing about this: the turns are already
608
- kept before it squashes.
624
+ doesn't lose it. `/gtd ship` pushes this process's own ref to origin alongside
625
+ the branch (never the others) and writes a `Gtd-History: <ref>` trailer on the
626
+ squash commit. The push is forced, so it replaces origin's copy of that one ref.
627
+ If that push fails, the ship fails and nothing is squashed. Recover the full
628
+ trail with `gtd history fetch | sh`, then `gtd history show <ref>`.
609
629
 
610
630
  ```sh
611
631
  gtd history list # one line per kept process
@@ -614,7 +634,9 @@ gtd history fetch | sh # pull every kept ref from origin
614
634
  ```
615
635
 
616
636
  `gtd history fetch` only prints a fetch script; pipe it into `sh` to run it.
617
- Refs are not pushed or fetched by default. To share them:
637
+ Beyond that one ship push, refs are not pushed or fetched by default. The ref
638
+ holds every step commit, including steering files and code a later step removed,
639
+ so anyone who can fetch the remote can read it. To share the rest:
618
640
 
619
641
  ```sh
620
642
  git push origin 'refs/gtd/history/*'
@@ -622,7 +644,9 @@ git config --add remote.origin.fetch 'refs/gtd/history/*:refs/gtd/history/*'
622
644
  ```
623
645
 
624
646
  The second line makes every plain `git fetch` include them. It has no leading
625
- `+`, so a conflicting local ref fails instead of being overwritten.
647
+ `+`, so a conflicting local ref fails instead of being overwritten: after a
648
+ forced `/gtd ship` push, a clone holding the old tip deletes its local ref
649
+ (`git update-ref -d <ref>`) and fetches again.
626
650
 
627
651
  A step declared with `keep: true` files its turn under `refs/gtd/kept/` instead
628
652
  of on the branch (see [Kept tips](docs/configuration.md#kept-tips)). Those refs
@@ -670,7 +694,9 @@ listed in the
670
694
  `gtd slices` lists the spans a flow's `slice()` calls mark. It refuses (exit 1)
671
695
  when HEAD neither closes nor sits inside a process, or when today's config
672
696
  disagrees with history about the process; a version mismatch only warns on
673
- stderr.
697
+ stderr. `gtd summary --slice <name>` prints the closing-message prompt for one
698
+ of those slices — its own range, human commits and cost — and inherits those
699
+ refusals; an unknown name is refused with every valid slice listed.
674
700
 
675
701
  ## License
676
702
 
@@ -32,3 +32,15 @@ export async function enter(io: ShipIo, name: string, args: string[]): Promise<S
32
32
  if (started.code !== 0) return fail(`The door script failed.\n${started.err.trim()}`)
33
33
  return { ok: true, text: `Started ${name}.` }
34
34
  }
35
+
36
+ // A door is entered only when the words fit its args; anything else is requirements.
37
+ export function doorFor(
38
+ listed: readonly Door[],
39
+ verb: string,
40
+ args: readonly string[],
41
+ ): Door | undefined {
42
+ const door = listed.find((d) => d.name === verb)
43
+ if (!door) return undefined
44
+ const required = door.args.filter((a) => !a.optional).length
45
+ return args.length >= required && args.length <= door.args.length ? door : undefined
46
+ }
@@ -7,7 +7,7 @@ import type { AccessDef } from "./access"
7
7
  import { jobPrompt, wakePrompt, wakes } from "./assist"
8
8
  import { afterReload, drive, isTrue } from "./drive"
9
9
  import type { Io, Opening, Turn, TurnEnd } from "./drive"
10
- import { doors, enter } from "./doors"
10
+ import { doorFor, doors, enter } from "./doors"
11
11
  import { subagentModel } from "./models"
12
12
  import { pastedCommand } from "./paste"
13
13
  import { personaSpec, resumable } from "./persona"
@@ -824,9 +824,7 @@ async function command($: $, arg: string): Promise<string | undefined> {
824
824
  case "":
825
825
  return resume($)
826
826
  default:
827
- return (await doors(shipIo($))).some((d) => d.name === verb)
828
- ? enterNow($, verb, rest)
829
- : begin($, arg)
827
+ return doorFor(await doors(shipIo($)), verb, rest) ? enterNow($, verb, rest) : begin($, arg)
830
828
  }
831
829
  }
832
830
 
@@ -53,7 +53,7 @@ export function costTrailers(log: string) {
53
53
  // `gtd summary` is the only place gtd names the process's start parent.
54
54
  // `gtd base` is the review anchor and would squash only part of the process.
55
55
  const summaryRange = (summary: string) =>
56
- /git log ([0-9a-f]{7,40})\.\.([0-9a-f]{7,40})/.exec(summary)?.slice(1, 3)
56
+ /git log ([0-9a-f]{7,64})\.\.([0-9a-f]{7,64})/.exec(summary)?.slice(1, 3)
57
57
 
58
58
  const COMMIT_FORMAT = `
59
59
  --- MESSAGE FORMAT (overrides any formatting the instructions above imply) ---
@@ -102,6 +102,18 @@ Nothing else. No file-by-file list, no per-commit narration, no implementation
102
102
  detail the diff already shows, no test-plan boilerplate, no sign-off, no footer
103
103
  trailers. Short headings and lists are fine. Under 250 words.`
104
104
 
105
+ // A rejection becomes `killed`, so the caller can tell a timeout from a refused
106
+ // push. Relies on the host's process runner rejecting at its timeout; a spawn
107
+ // failure lands here too. Only pushes: any other crashed read must abort the
108
+ // ship, not pass as an answer.
109
+ const attempt = async (io: ShipIo, argv: string[]): Promise<Ran & { killed: boolean }> => {
110
+ try {
111
+ return { ...(await io.run(argv)), killed: false }
112
+ } catch (err) {
113
+ return { code: 1, out: "", err: err instanceof Error ? err.message : String(err), killed: true }
114
+ }
115
+ }
116
+
105
117
  export async function ship(io: ShipIo, isDry: boolean): Promise<Shipped> {
106
118
  const git = async (...args: string[]) => (await io.run(["git", ...args])).out.trim()
107
119
  const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
@@ -125,7 +137,7 @@ export async function ship(io: ShipIo, isDry: boolean): Promise<Shipped> {
125
137
 
126
138
  const squashed = await squash(io, git, ok, branch, isDry)
127
139
  if (!squashed.ok) return squashed
128
- if (isDry) return preview(io, git, branch, baseRef, squashed.text)
140
+ if (isDry) return preview(io, git, branch, baseRef, squashed.text, squashed.history)
129
141
  return pullRequest(io, git, ok, branch, baseBranch, baseRef)
130
142
  }
131
143
 
@@ -154,7 +166,7 @@ async function squash(
154
166
  ok: Ok,
155
167
  branch: string,
156
168
  isDry: boolean,
157
- ): Promise<Shipped> {
169
+ ): Promise<Shipped & { history?: string }> {
158
170
  const summary = await io.run(["gtd", "summary"])
159
171
  if (summary.code !== 0) {
160
172
  io.log(`No gtd process at HEAD, so nothing is squashed; ${branch} is described as it stands.`)
@@ -172,24 +184,62 @@ async function squash(
172
184
  const { base, tip, count } = range
173
185
 
174
186
  // gtd churns its own steering files every process, so `.gtd/` alone is no change.
175
- if (await ok("diff", "--quiet", base, "HEAD", "--", ":(exclude).gtd/")) {
176
- if (isDry)
177
- return { ok: true, text: `No file changes outside .gtd/: would drop ${count} commit(s).` }
178
- if (!(await ok("reset", "--hard", base))) return fail("git reset failed; nothing changed.")
179
- io.log(
180
- `No file changes outside .gtd/: dropped ${count} commit(s). Undo with git reset --hard ORIG_HEAD.`,
181
- )
182
- return { ok: true, text: "" }
183
- }
187
+ if (await ok("diff", "--quiet", base, "HEAD", "--", ":(exclude).gtd/"))
188
+ return dropProcess(io, ok, base, count, isDry)
184
189
 
185
190
  io.log(`Squashing ${count} commit(s) since ${short(base)}…`)
186
191
  const trailers = costTrailers(await git("log", "--format=%B%n", `${base}..${tip}`))
192
+ const history = await historyRef(io, tip)
193
+ if (history) trailers.push(`Gtd-History: ${history}`)
187
194
  const reply = await io.complete(summary.out + "\n" + COMMIT_FORMAT)
188
195
  const message = reply && cleanReply(reply)
189
196
  if (!message) return fail("The commit-message turn produced nothing.")
190
197
  const full = trailers.length ? `${message}\n\n${trailers.join("\n")}` : message
191
- if (isDry) return { ok: true, text: full }
198
+ if (isDry) return { ok: true, text: full, history }
199
+
200
+ return commitSquash(io, git, ok, base, full, history)
201
+ }
192
202
 
203
+ async function dropProcess(
204
+ io: ShipIo,
205
+ ok: Ok,
206
+ base: string,
207
+ count: number,
208
+ isDry: boolean,
209
+ ): Promise<Shipped> {
210
+ if (isDry)
211
+ return { ok: true, text: `No file changes outside .gtd/: would drop ${count} commit(s).` }
212
+ if (!(await ok("reset", "--hard", base))) return fail("git reset failed; nothing changed.")
213
+ io.log(
214
+ `No file changes outside .gtd/: dropped ${count} commit(s). Undo with git reset --hard ORIG_HEAD.`,
215
+ )
216
+ return { ok: true, text: "" }
217
+ }
218
+
219
+ async function commitSquash(
220
+ io: ShipIo,
221
+ git: Git,
222
+ ok: Ok,
223
+ base: string,
224
+ full: string,
225
+ history: string | undefined,
226
+ ): Promise<Shipped> {
227
+ // Before the squash: a failure leaves the branch as it was, so a retry finds the process.
228
+ if (history) {
229
+ // Forced so a retry after the tip changed lands. The ref is keyed by the entry
230
+ // commit, which every clone of the process shares: a second clone shipping the
231
+ // same entry replaces origin's copy.
232
+ io.log(`Pushing ${history} to origin: the process's step commits become fetchable there.`)
233
+ const pushed = await attempt(io, ["git", "push", "origin", `+${history}:${history}`])
234
+ if (pushed.killed)
235
+ return fail(
236
+ `git push of ${history} did not finish; nothing was squashed. Retry.\n${pushed.err}`.trimEnd(),
237
+ )
238
+ if (pushed.code !== 0)
239
+ return fail(
240
+ `git push of ${history} failed; nothing was squashed or pushed.\n${pushed.err}`.trimEnd(),
241
+ )
242
+ }
193
243
  if (!(await ok("reset", "--soft", base))) return fail("git reset failed; nothing changed.")
194
244
  const commit = await io.run(["git", "commit", "--cleanup=strip", "-F", "-"], full + "\n")
195
245
  if (commit.code !== 0) {
@@ -203,7 +253,39 @@ async function squash(
203
253
  return { ok: true, text: "" }
204
254
  }
205
255
 
206
- async function preview(io: ShipIo, git: Git, branch: string, baseRef: string, message: string) {
256
+ async function historyRef(io: ShipIo, tip: string) {
257
+ const list = await io.run(["gtd", "history", "list", "--json"])
258
+ let entries: { ref?: string; entry?: string; tip?: string }[] = []
259
+ try {
260
+ entries = JSON.parse(list.out)
261
+ } catch {
262
+ // unreadable list reads as no entry
263
+ }
264
+ // The ref becomes a refspec: push it only when it is exactly the full-sha
265
+ // name of its entry (40 hex, or 64 under SHA-256).
266
+ const found = Array.isArray(entries)
267
+ ? entries.find(
268
+ (e) =>
269
+ e.tip === tip &&
270
+ /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(e.entry ?? "") &&
271
+ e.ref === `refs/gtd/history/${e.entry}`,
272
+ )
273
+ : undefined
274
+ if (!found) {
275
+ io.log("No gtd history ref keeps this process: shipping without a Gtd-History trailer.")
276
+ return undefined
277
+ }
278
+ return found.ref
279
+ }
280
+
281
+ async function preview(
282
+ io: ShipIo,
283
+ git: Git,
284
+ branch: string,
285
+ baseRef: string,
286
+ message: string,
287
+ history?: string,
288
+ ) {
207
289
  const mergeBase = await git("merge-base", baseRef, "HEAD")
208
290
  const existing = await io.run(["gh", "pr", "view", "--json", "url"])
209
291
  const pr =
@@ -212,7 +294,11 @@ async function preview(io: ShipIo, git: Git, branch: string, baseRef: string, me
212
294
  : `would open a pull request for ${branch} into ${baseRef} (${await git("rev-list", "--count", `${mergeBase}..HEAD`)} commit(s) before the squash)`
213
295
  return {
214
296
  ok: true,
215
- text: [message && `Commit message:\n\n${message}`, `Pull request: ${pr}.`]
297
+ text: [
298
+ message && `Commit message:\n\n${message}`,
299
+ history && `History: would push ${history} to origin.`,
300
+ `Pull request: ${pr}.`,
301
+ ]
216
302
  .filter(Boolean)
217
303
  .join("\n\n"),
218
304
  }
@@ -256,14 +342,23 @@ async function pullRequest(
256
342
  // the push failed, or nothing.
257
343
  export async function pushBranch(io: ShipIo, branch: string) {
258
344
  const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
345
+ const push = async (args: string[], failure: string) => {
346
+ const r = await attempt(io, ["git", "push", ...args])
347
+ if (r.killed)
348
+ return `git push of ${branch} did not finish; nothing was lost. Retry without fetching.\n${r.err}`.trimEnd()
349
+ if (r.code !== 0) return `${failure}\n${r.err}`.trimEnd()
350
+ return undefined
351
+ }
259
352
  if (!(await ok("rev-parse", "--verify", "--quiet", `origin/${branch}`))) {
260
353
  io.log(`Pushing ${branch} to origin…`)
261
- if (!(await ok("push", "--set-upstream", "origin", branch))) return "git push failed."
262
- } else if (!(await ok("merge-base", "--is-ancestor", "HEAD", `origin/${branch}`))) {
354
+ return push(["--set-upstream", "origin", branch], "git push failed.")
355
+ }
356
+ if (!(await ok("merge-base", "--is-ancestor", "HEAD", `origin/${branch}`))) {
263
357
  io.log(`Pushing ${branch} to origin…`)
264
- if (!(await ok("push", "--force-with-lease", "origin", branch))) {
265
- return `git push failed: origin/${branch} moved. Fetch and reconcile first.`
266
- }
358
+ return push(
359
+ ["--force-with-lease", "origin", branch],
360
+ `git push failed: origin/${branch} moved. Fetch and reconcile first.`,
361
+ )
267
362
  }
268
363
  return undefined
269
364
  }