@pmelab/gtd 29.2.3 → 29.2.4

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.2.3",
3
+ "version": "29.2.4",
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,15 +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; re-running `/gtd ship` after a lower
345
- stacked pull request merged restacks the rest (it refuses, with nothing changed,
346
- when that pull request merged anywhere but the default branch (unless into a
347
- lower slice branch before that one merged into it), a slice branch is checked
348
- out in another worktree, an unrelated local branch sits inside the rebased
349
- commits, or a slice's change is already upstream; a failed rebase is rolled
350
- back). A process can change hands at any gate: ask Claude to hand it off (or run
351
- `/gtd throw @dev`) and it opens a draft pull request assigned to them, and
352
- `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
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). A process can change hands at any gate: ask Claude to hand it off
352
+ (or run `/gtd throw @dev`) and it opens a draft pull request assigned to them,
353
+ and `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
353
354
  requirements can hand the architecture to someone else. Ship, hand-off and throw
354
355
  answer at once and run in the background, with progress on the status line; they
355
356
  report by waking Claude, a toast, a transcript line and a push notification, and
@@ -633,7 +633,13 @@ export const NO_PR_FOUND = "no pull requests found"
633
633
 
634
634
  // `gh pr view <branch>`: only NO_PR_FOUND reads as none; any
635
635
  // other failure (auth, network) must refuse, or ship would open a duplicate PR.
636
- async function viewPr(io: ShipIo, branch: string): Promise<View | undefined | string> {
636
+ // A closed PR whose branch origin lost is an abandoned earlier ship and reads as
637
+ // none; `abandoned` is recorded before the push so a retry, with origin holding
638
+ // the branch again, still reads it as none. `gone`: a tracking ref is stale.
639
+ async function readPr(
640
+ io: ShipIo,
641
+ branch: string,
642
+ ): Promise<{ view?: View; gone?: boolean; abandoned?: number } | string> {
637
643
  const v = await io.run([
638
644
  "gh",
639
645
  "pr",
@@ -643,14 +649,41 @@ async function viewPr(io: ShipIo, branch: string): Promise<View | undefined | st
643
649
  "number,title,body,url,state,baseRefName,mergedAt",
644
650
  ])
645
651
  if (v.code !== 0) {
646
- if (v.err.includes(NO_PR_FOUND)) return undefined
652
+ if (v.err.includes(NO_PR_FOUND)) return {}
647
653
  return `gh pr view ${branch} failed: ${v.err.trim()}`
648
654
  }
655
+ let view: View
649
656
  try {
650
- return JSON.parse(v.out) as View
657
+ view = JSON.parse(v.out) as View
651
658
  } catch {
652
659
  return `gh pr view ${branch} returned something unreadable.`
653
660
  }
661
+ if (view.state !== "CLOSED") return { view }
662
+ const key = await io.run(["git", "config", "--local", "--get", ABANDONED_KEY(branch)])
663
+ const recorded = key.out.trim() === String(view.number)
664
+ const ls = await io.run(["git", "ls-remote", "origin", `refs/heads/${branch}`])
665
+ const gone = ls.code === 0 && !ls.out.trim()
666
+ if (recorded) return { gone }
667
+ return gone ? { gone, abandoned: view.number } : { view }
668
+ }
669
+
670
+ const ABANDONED_KEY = (branch: string) => `branch.${branch}.gtdAbandonedPr`
671
+
672
+ // Once the new PR opens, gh pr view finds it: the record has served its purpose.
673
+ async function settled<T extends Shipped>(io: ShipIo, branch: string, done: T) {
674
+ if (done.ok) await io.run(["git", "config", "--local", "--unset", ABANDONED_KEY(branch)])
675
+ return done
676
+ }
677
+
678
+ async function abandon(io: ShipIo, branch: string, pr: number | undefined) {
679
+ if (pr === undefined) return undefined
680
+ const set = await io.run(["git", "config", "--local", ABANDONED_KEY(branch), String(pr)])
681
+ return set.code === 0 ? undefined : `git config failed.\n${set.err}`.trimEnd()
682
+ }
683
+
684
+ async function viewPr(io: ShipIo, branch: string): Promise<View | undefined | string> {
685
+ const read = await readPr(io, branch)
686
+ return typeof read === "string" ? read : read.view
654
687
  }
655
688
 
656
689
  // `dead` names belong to a retired record gtd built itself: not a collision.
@@ -1512,10 +1545,13 @@ async function shipLink(
1512
1545
  const l = links[i]!
1513
1546
  const lower = i ? links[i - 1]! : undefined
1514
1547
  const base = baseOf(links, i, baseBranch)
1515
- const view = await viewPr(io, l.branch)
1516
- if (typeof view === "string")
1517
- return { step: "pull request", why: `${view} ${l.branch} was not pushed; /gtd ship retries.` }
1518
- const pushed = await pushLink(io, git, l, i < links.length - 1)
1548
+ const read = await readPr(io, l.branch)
1549
+ if (typeof read === "string")
1550
+ return { step: "pull request", why: `${read} ${l.branch} was not pushed; /gtd ship retries.` }
1551
+ const { view, gone } = read
1552
+ const marked = await abandon(io, l.branch, read.abandoned)
1553
+ if (marked) return { step: "push", why: marked }
1554
+ const pushed = await pushLink(io, git, { ...l, fresh: l.fresh || gone }, i < links.length - 1)
1519
1555
  if (pushed) return { step: "push", why: pushed }
1520
1556
  const lowerRef = lower?.branch ?? baseRef
1521
1557
  const ctx: Ctx = {
@@ -1528,6 +1564,7 @@ async function shipLink(
1528
1564
  }
1529
1565
  const done = await sliceStep(io, git, ok, l, view, base, lowerRef, ctx)
1530
1566
  if ("step" in done) return done
1567
+ if (!view) await settled(io, l.branch, done)
1531
1568
  if (done.number === undefined && i < links.length - 1)
1532
1569
  return { step: "pull request", why: "its number could not be read from gh's reply." }
1533
1570
  return done
@@ -1764,17 +1801,24 @@ async function pullRequest(
1764
1801
  if (mergeBase === (await git("rev-parse", "HEAD")))
1765
1802
  return fail(`HEAD is an ancestor of ${baseRef}: nothing to describe.`)
1766
1803
 
1767
- const existing = await viewPr(io, branch)
1768
- if (typeof existing === "string")
1804
+ const read = await readPr(io, branch)
1805
+ if (typeof read === "string")
1769
1806
  return fail(
1770
- `${existing} ${branch} was not pushed; the squash and history ref stay, /gtd ship retries.`,
1807
+ `${read} ${branch} was not pushed; the squash and history ref stay, /gtd ship retries.`,
1771
1808
  )
1809
+ const { view: existing, gone } = read
1772
1810
  if (existing && existing.state !== "OPEN")
1773
1811
  return fail(`The pull request for ${branch} is ${existing.state}, not open.`)
1774
- const pushed = await pushBranch(io, branch)
1812
+ const marked = await abandon(io, branch, read.abandoned)
1813
+ if (marked) return fail(`${marked} ${branch} was not pushed; /gtd ship retries.`)
1814
+ const pushed = await pushBranch(io, branch, "HEAD", undefined, gone)
1775
1815
  if (pushed) return fail(pushed)
1776
1816
  if (!existing)
1777
- return createPr(io, git, branch, baseBranch, baseRef, { from: mergeBase, to: "HEAD" })
1817
+ return settled(
1818
+ io,
1819
+ branch,
1820
+ await createPr(io, git, branch, baseBranch, baseRef, { from: mergeBase, to: "HEAD" }),
1821
+ )
1778
1822
  // A branch once shipped as a stack keeps the PR aimed at a slice branch; any
1779
1823
  // other base is the author's own and stays.
1780
1824
  const onSlice = recorded.some((n) => existing.baseRefName === `${branch}-${n}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "29.2.3",
3
+ "version": "29.2.4",
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": {