@pmelab/gtd 29.3.0 → 29.3.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "29.3.0",
3
+ "version": "29.3.1",
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
@@ -341,14 +341,16 @@ wait for the next rest. When the process finishes, Claude offers to squash it
341
341
  and open its pull request(s) (or run `/gtd ship`; `GTD_SHIP_MODE` picks one
342
342
  stacked pull request per slice or one commit per slice, see the
343
343
  [driver doc](docs/driver.md)); a process started for an issue ships with
344
- `Closes #N` on its top pull request; a closed pull request whose branch was
345
- deleted from origin is left behind and a new one opens; re-running `/gtd ship`
346
- after a lower stacked pull request merged restacks the rest (it refuses, with
347
- nothing changed, when that pull request merged anywhere but the default branch
348
- (unless into a lower slice branch before that one merged into it), a slice
349
- branch is checked out in another worktree, an unrelated local branch sits inside
350
- the rebased commits, or a slice's change is already upstream; a failed rebase is
351
- rolled back; to tear a stack down from its worktree, delete the top branch with
344
+ `Closes #N` on its top pull request; a later process on an already-stacked
345
+ branch, such as review fixes, lands one commit per slice on the top pull request
346
+ and keeps the stack; a closed pull request whose branch was deleted from origin
347
+ is left behind and a new one opens; re-running `/gtd ship` after a lower stacked
348
+ pull request merged restacks the rest (it refuses, with nothing changed, when
349
+ that pull request merged anywhere but the default branch (unless into a lower
350
+ slice branch before that one merged into it), a slice branch is checked out in
351
+ another worktree, an unrelated local branch sits inside the rebased commits, or
352
+ a slice's change is already upstream; a failed rebase is rolled back; to tear a
353
+ stack down from its worktree, delete the top branch with
352
354
  `git push origin --delete`, since `gh pr close --delete-branch` cannot). A
353
355
  process can change hands at any gate: ask Claude to hand it off (or run
354
356
  `/gtd throw @dev`) and it opens a draft pull request assigned to them, and
@@ -495,8 +495,7 @@ async function squash(
495
495
  if (!msgs.ok) return msgs
496
496
  const { fulls, history } = msgs
497
497
  const warn = () => (mode === "commits" && many ? rebaseWarning(io) : undefined)
498
- if (resume?.links)
499
- return squashOntoStack(io, git, ok, groups[0]!, fulls[0]!, history, resume, isDry, tip)
498
+ if (resume?.links) return squashOntoStack(io, git, ok, groups, fulls, history, resume, isDry, tip)
500
499
  if (isDry)
501
500
  return dryRun(
502
501
  groups,
@@ -552,18 +551,21 @@ async function stackReads(
552
551
  mode: ShipMode,
553
552
  ): Promise<Resumed | string | undefined> {
554
553
  if (mode !== "stack") return undefined
555
- if (groups.length > 1) return await stackPrecheck(io, git, ok, branch, baseBranch, groups)
554
+ if (groups.length > 1)
555
+ return await stackPrecheck(io, git, ok, branch, baseBranch, groups, base, isDry)
556
556
  return resumable(io, git, ok, branch, baseBranch, isDry, base)
557
557
  }
558
558
 
559
- // One slice on a recorded stack: its commit becomes the new top of the stack, and
560
- // the record, including the recorded top the missing-branch fallbacks read, stays.
559
+ // A process on a recorded stack — a re-ship, or a follow-up such as review fixes —
560
+ // lands its slice commits on the top branch, so they join the top pull request.
561
+ // The record, including the recorded top the missing-branch fallbacks and the
562
+ // restack read, stays.
561
563
  async function squashOntoStack(
562
564
  io: ShipIo,
563
565
  git: Git,
564
566
  ok: Ok,
565
- group: Group,
566
- full: string,
567
+ groups: Group[],
568
+ fulls: string[],
567
569
  history: string | undefined,
568
570
  resume: Resumed,
569
571
  isDry: boolean,
@@ -572,16 +574,16 @@ async function squashOntoStack(
572
574
  const links = resume.links!
573
575
  const top = links.length - 1
574
576
  if (isDry) {
575
- const messages = links.map((_, i) => (i === top ? full : ""))
576
- return {
577
- ...dryRun([group], [full], history, undefined, links),
578
- messages,
579
- restack: resume.restack,
580
- }
577
+ // the follow-up's slice names are not stack slices: no [name] headers
578
+ const messages = links.map((_, i) => (i === top ? fulls.join("\n\n") : ""))
579
+ return { ok: true as const, text: "", history, stack: links, messages, restack: resume.restack }
581
580
  }
582
581
  const pushed = await pushHistory(io, history)
583
582
  if (pushed) return fail(pushed)
584
- const one = await commitSquash(io, git, ok, group.base, full)
583
+ const one =
584
+ groups.length === 1
585
+ ? await commitSquash(io, git, ok, groups[0]!.base, fulls[0]!)
586
+ : await commitChain(io, git, ok, groups, fulls)
585
587
  if (!one.ok) return one
586
588
  const failed = await resume.revive?.()
587
589
  if (failed) return fail(failed)
@@ -709,24 +711,30 @@ async function takenSlice(git: Git, ok: Ok, branch: string, groups: Group[], dea
709
711
  return undefined
710
712
  }
711
713
 
712
- async function recordedRefusal(
714
+ // A live record takes the process onto its top branch; a dead one (no links)
715
+ // leaves the way open for a fresh stack.
716
+ async function liveRecord(
713
717
  io: ShipIo,
714
718
  git: Git,
715
719
  ok: Ok,
716
720
  branch: string,
717
721
  baseBranch: string,
718
722
  recorded: string[],
719
- ): Promise<string | undefined> {
720
- // The staleness test a resume uses: a dead record is not a live stack.
723
+ base: string,
724
+ isDry: boolean,
725
+ ): Promise<Resumed | string> {
726
+ // The staleness test a resume uses, without the restack's origin and worktree
727
+ // reads: a dead record is not a live stack, and its dropped-record line is
728
+ // silenced since a fresh stack replaces it.
721
729
  const quiet = { ...io, log: () => {} }
722
730
  const found = await resumable(quiet, git, ok, branch, baseBranch, true, "HEAD", false)
723
731
  // resumable refuses (a missing branch, a closed slice PR) before it tests the
724
732
  // chain; only a record that still chains to HEAD may refuse a fresh stack.
725
- if (typeof found === "string")
726
- return (await chainsToHead(git, ok, branch, recorded)) ? found : undefined
727
- if (!found.forget)
728
- return `${branch} already has a recorded stack (${recorded.join(", ")}); nothing changed. Ship with GTD_SHIP_MODE=commits, or run git config --local --unset branch.${branch}.gtdStack and git config --local --unset branch.${branch}.gtdStackTop, delete or rename the ${branch}-<slice> branches, then ship again.`
729
- return undefined
733
+ if (typeof found === "string") return (await chainsToHead(git, ok, branch, recorded)) ? found : {}
734
+ // `forget` with links is a live stack a restack ends, not a dead record.
735
+ if (!found.links) return {}
736
+ // Live: any refusal of the full resume (origin, worktrees) stands.
737
+ return resumable(io, git, ok, branch, baseBranch, isDry, base)
730
738
  }
731
739
 
732
740
  async function chainsToHead(git: Git, ok: Ok, branch: string, recorded: string[]) {
@@ -760,11 +768,14 @@ async function stackPrecheck(
760
768
  branch: string,
761
769
  baseBranch: string,
762
770
  groups: Group[],
763
- ): Promise<string | undefined> {
771
+ base: string,
772
+ isDry: boolean,
773
+ ): Promise<Resumed | string | undefined> {
764
774
  const recorded = names(await git("config", "--local", "--get", STACK_KEY(branch)))
765
775
  if (recorded.length) {
766
- const refused = await recordedRefusal(io, git, ok, branch, baseBranch, recorded)
767
- if (refused) return refused
776
+ const live = await liveRecord(io, git, ok, branch, baseBranch, recorded, base, isDry)
777
+ // Without links the squash builds a fresh stack, so the collision checks still run.
778
+ if (typeof live === "string" || live?.links) return live
768
779
  }
769
780
  const retired = names(await git("config", "--local", "--get", RETIRED_KEY(branch)))
770
781
  const taken = await takenSlice(git, ok, branch, groups, [...recorded, ...retired])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "29.3.0",
3
+ "version": "29.3.1",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {