jackherd 1.26.1 → 1.26.3

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 (51) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +18 -18
  3. package/dist/config.d.ts +1 -0
  4. package/dist/config.js +2 -0
  5. package/herdr-plugin/herdr-plugin.toml +1 -1
  6. package/install.sh +1 -1
  7. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -8
  8. package/kit/workflow/skills/orchestrate/references/lane-session.md +1 -1
  9. package/package.json +1 -1
  10. package/schemas/jackherd.schema.json +8 -0
  11. package/src/brief/lane-facts.ts +30 -18
  12. package/src/config/schema.ts +2 -0
  13. package/src/context/command.ts +3 -1
  14. package/src/lane/close-check.ts +51 -15
  15. package/src/lane/close-command.ts +41 -8
  16. package/src/lane/close-survey.ts +9 -0
  17. package/src/lane/close.ts +54 -6
  18. package/src/lane/send-options.ts +3 -2
  19. package/src/lane/send.ts +8 -1
  20. package/src/lane/subagent-view.ts +21 -0
  21. package/src/lane-prompt/command.ts +4 -1
  22. package/src/plans/active-tasks.ts +4 -2
  23. package/src/plans/activity-read.ts +3 -2
  24. package/src/plans/command.ts +3 -1
  25. package/src/plans/coordinator-row.ts +7 -2
  26. package/src/plans/ensure-panes.ts +2 -2
  27. package/src/plans/lane-discovery.ts +42 -3
  28. package/src/plans/lane-worktrees.ts +56 -0
  29. package/src/plans/sent-files.ts +10 -5
  30. package/src/plans/settings.ts +12 -1
  31. package/src/plans/snapshot.ts +23 -8
  32. package/src/plans/subagents-read.ts +17 -2
  33. package/src/plans/subagents.ts +3 -2
  34. package/src/plans/unlisted-lanes.ts +13 -0
  35. package/src/resume/command.ts +1 -1
  36. package/src/resume/coordinator-read.ts +12 -12
  37. package/src/resume/decision.ts +3 -0
  38. package/src/resume/record.ts +24 -5
  39. package/src/sync/command.ts +16 -2
  40. package/src/sync/downgrade.ts +9 -0
  41. package/src/sync/if-changed.ts +6 -7
  42. package/src/update/command.ts +17 -0
  43. package/src/update/registry-wait.ts +44 -0
  44. package/src/update/update.ts +23 -0
  45. package/src/version/registry.ts +8 -0
  46. package/src/watch/command.ts +28 -14
  47. package/src/watch/obsolete-proposals.ts +8 -6
  48. package/src/watch/obsolete-records.ts +3 -1
  49. package/src/watch/running-subagents.ts +3 -2
  50. package/src/watch/task-signal-read.ts +4 -1
  51. package/src/watch/unlinked-artifacts-read.ts +3 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  What changed for people who use jackherd, newest first.
4
4
 
5
+ ## 1.26.3
6
+
7
+ - **`jh lane close` no longer refuses a lane for files a project deletes on every close.** `lanes.disposable` in `.jackherd/config.json` lists ignored paths in `.gitignore` syntax (scratch folders, stored test logins, generated assets) that go with the worktree without a refusal; any other ignored file still refuses.
8
+ - **`jh lane close` archives the lane's handover files instead of refusing.** Before the worktree is removed, `.claude/handover/` is copied to `.claude/handover/closed/<lane>-<date>/` in the main checkout; when the copy fails, nothing is removed. The orchestrate skill's `close-lane.md` says so. Run `jh sync` to get the text.
9
+ - **`jh lane close` deletes the lane's branch on origin when nothing was pushed after the merge.** The branch goes only while it still points to the merged pull request's head, as the last step so a failed push leaves the rest done; otherwise one line says why it stays. `jh lane close <lane> --branch <branch>` finishes it for a lane closed before.
10
+
11
+ ## 1.26.2
12
+
13
+ - **A board lane is placed by its agent's worktree.** The herd and the watcher find a lane by the worktree its agent runs in, not only by the lane's name.
14
+ - **`jh sync` refuses a sync that would turn the repo's kit files back.** When the installed jackherd is older than the version in the repo's lock, the sync stops and names both versions instead of overwriting newer kit files. Run `jh update` first.
15
+ - **`jh update` waits for the registry after a publish.** A 404 right after a release no longer fails the update; it retries until the registry has the version.
16
+ - **`jh lane send` refuses while a background agent's view is open.** The text no longer lands in the subagent view instead of the lane's session.
17
+
5
18
  ## 1.26.1
6
19
 
7
20
  - **A lane whose agent runs in a worktree of another name is no longer silently unwatched.** The watcher finds a lane by its worktree directory's name, so a board lane whose agent runs in a worktree taken over from another session never sent an event. The watcher now prints `lane <name> not watched: its agent runs in worktree <dir>, not <name>` once when it appears, and `jh resume --coordinator` lists it too. The orchestrate skill's `lane-session.md` says to name the lane after the worktree directory, and to rename its row and agent when the line appears. Run `jh sync` to get the text.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  <p>
2
- <img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.26.1/assets/branding/jackherd-logo-outline.svg" width="340">
2
+ <img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.26.3/assets/branding/jackherd-logo-outline.svg" width="340">
3
3
  <br>
4
4
  </p>
5
5
 
6
- [![npm](https://img.shields.io/npm/v/jackherd)](https://www.npmjs.com/package/jackherd) [![license: MIT](https://img.shields.io/npm/l/jackherd)](https://www.npmjs.com/package/jackherd) [![2 dependencies](https://img.shields.io/badge/dependencies-2-brightgreen)](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [![macOS](https://img.shields.io/badge/macOS-supported-brightgreen?logo=apple)](https://unpkg.com/jackherd/INSTALL.md) [![Linux](https://img.shields.io/badge/Linux-supported-brightgreen?logo=linux&logoColor=white)](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.1/assets/icons/space.svg">
6
+ [![npm](https://img.shields.io/npm/v/jackherd)](https://www.npmjs.com/package/jackherd) [![license: MIT](https://img.shields.io/npm/l/jackherd)](https://www.npmjs.com/package/jackherd) [![2 dependencies](https://img.shields.io/badge/dependencies-2-brightgreen)](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [![macOS](https://img.shields.io/badge/macOS-supported-brightgreen?logo=apple)](https://unpkg.com/jackherd/INSTALL.md) [![Linux](https://img.shields.io/badge/Linux-supported-brightgreen?logo=linux&logoColor=white)](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.3/assets/icons/space.svg">
7
7
 
8
8
  # Jack herds your coding agents.
9
9
 
@@ -15,29 +15,29 @@ For developers who'd rather decide than supervise.
15
15
  curl -fsSL https://unpkg.com/jackherd/install.sh | sh
16
16
  ```
17
17
 
18
- Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.1/assets/icons/space.svg">
18
+ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.3/assets/icons/space.svg">
19
19
 
20
20
  <p>
21
- <img src="https://unpkg.com/jackherd@1.26.1/docs/workflow-dark.svg" width="720" alt="jackherd in herdr: a lane hits a question, Jack asks you as a choice, you pick, and the lane works on while the herd view counts its progress.">
21
+ <img src="https://unpkg.com/jackherd@1.26.3/docs/workflow-dark.svg" width="720" alt="jackherd in herdr: a lane hits a question, Jack asks you as a choice, you pick, and the lane works on while the herd view counts its progress.">
22
22
  <br>
23
- <sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.1/assets/icons/space.svg">
23
+ <sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.3/assets/icons/space.svg">
24
24
  </p>
25
25
 
26
- <p><img src="https://unpkg.com/jackherd@1.26.1/assets/icons/lanes.svg" width="24" height="34" align="middle" alt=""> <b>Parallel where it pays.</b><br>Phases that don't collide run side by side, each lane on its own branch and worktree. Jack keeps the merge order and has the others rebase.</p>
26
+ <p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/lanes.svg" width="24" height="34" align="middle" alt=""> <b>Parallel where it pays.</b><br>Phases that don't collide run side by side, each lane on its own branch and worktree. Jack keeps the merge order and has the others rebase.</p>
27
27
 
28
- <p><img src="https://unpkg.com/jackherd@1.26.1/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Commits, pushes and merges wait for your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours. The guards catch mistakes, not intent; a hard boundary takes branch protection.</p>
28
+ <p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Commits, pushes and merges wait for your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours. The guards catch mistakes, not intent; a hard boundary takes branch protection.</p>
29
29
 
30
- <p><img src="https://unpkg.com/jackherd@1.26.1/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A lane gets stuck, a check goes red, a decision needs you: Jack steps in and brings you only what's yours to decide. The herd view shows where every lane stands.</p>
30
+ <p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A lane gets stuck, a check goes red, a decision needs you: Jack steps in and brings you only what's yours to decide. The herd view shows where every lane stands.</p>
31
31
 
32
- <p><img src="https://unpkg.com/jackherd@1.26.1/assets/icons/skills.svg" width="24" height="34" align="middle" alt=""> <b>Works with your skills.</b><br>Keep your own skills: a lane builds with whichever one you pick. Plans written elsewhere run too, once Jack fills in what they're missing.</p>
32
+ <p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/skills.svg" width="24" height="34" align="middle" alt=""> <b>Works with your skills.</b><br>Keep your own skills: a lane builds with whichever one you pick. Plans written elsewhere run too, once Jack fills in what they're missing.</p>
33
33
 
34
- <p><img src="https://unpkg.com/jackherd@1.26.1/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments on a lane's pull request go back to that lane, which fixes them with a test. <code>/jh-follow</code> brings in anything a CLI or an API can read as JSON: tickets, error logs, status pages.</p>
34
+ <p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments on a lane's pull request go back to that lane, which fixes them with a test. <code>/jh-follow</code> brings in anything a CLI or an API can read as JSON: tickets, error logs, status pages.</p>
35
35
 
36
- <p><img src="https://unpkg.com/jackherd@1.26.1/assets/icons/model.svg" width="24" height="34" align="middle" alt=""> <b>The right model for each phase.</b><br>Every phase names its model: the stronger one where a mistake is expensive (data, security, a migration), the cheaper one for the rest. Lanes start on it, and a hook refuses to start a phase's subagent on a weaker one.</p>
36
+ <p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/model.svg" width="24" height="34" align="middle" alt=""> <b>The right model for each phase.</b><br>Every phase names its model: the stronger one where a mistake is expensive (data, security, a migration), the cheaper one for the rest. Lanes start on it, and a hook refuses to start a phase's subagent on a weaker one.</p>
37
37
 
38
- <p><img src="https://unpkg.com/jackherd@1.26.1/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>A crash or a full context doesn't cost you the work.</b><br>Plan, board and a hand-over file hold the state, not the chat: the next session picks up where the last one stopped.</p>
38
+ <p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>A crash or a full context doesn't cost you the work.</b><br>Plan, board and a hand-over file hold the state, not the chat: the next session picks up where the last one stopped.</p>
39
39
 
40
- <p><img src="https://unpkg.com/jackherd@1.26.1/assets/icons/plan.svg" width="24" height="34" align="middle" alt=""> <b>Lives in your repository.</b><br>Plans, board and every decision are Markdown, reviewed like code. <code>jh init</code> gives your team the same setup.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.1/assets/icons/space.svg"></p>
40
+ <p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/plan.svg" width="24" height="34" align="middle" alt=""> <b>Lives in your repository.</b><br>Plans, board and every decision are Markdown, reviewed like code. <code>jh init</code> gives your team the same setup.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.3/assets/icons/space.svg"></p>
41
41
 
42
42
  ## Quickstart
43
43
 
@@ -68,22 +68,22 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
68
68
  /jh-orchestrate docs/plans/board-<name>/board.md
69
69
  ```
70
70
 
71
- Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.1/assets/icons/space.svg">
71
+ Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.3/assets/icons/space.svg">
72
72
 
73
73
  ## Follow anything you can query
74
74
 
75
75
  Tickets, error logs, status pages: if a command or an API call can read it as JSON with your login, Jack can watch it. Recipes cover Jira, Linear, GitHub issues and Projects, Sentry, Google Calendar and status pages; for anything else, `/jh-follow` writes the command with you. One yes, and every change shows up like this:
76
76
 
77
- <img src="https://unpkg.com/jackherd@1.26.1/docs/follow-dark.svg" width="720" alt="A source (ticket, error log or status page) has a new or changed entry; jh watch reports it to Jack; Jack puts a row into Your turn, prefixed with the source's label; its [ask] button makes Jack ask you, and then a lane works it.">
77
+ <img src="https://unpkg.com/jackherd@1.26.3/docs/follow-dark.svg" width="720" alt="A source (ticket, error log or status page) has a new or changed entry; jh watch reports it to Jack; Jack puts a row into Your turn, prefixed with the source's label; its [ask] button makes Jack ask you, and then a lane works it.">
78
78
 
79
- The herd sums up your sources in one `Sources` line: new entries per source, or its last run, red while it fails; opened, a click opens an entry. One that needs you lands in Your turn under the source's label, [ask] has Jack look at it, and a ticket shows on the lane that works it.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.1/assets/icons/space.svg">
79
+ The herd sums up your sources in one `Sources` line: new entries per source, or its last run, red while it fails; opened, a click opens an entry. One that needs you lands in Your turn under the source's label, [ask] has Jack look at it, and a ticket shows on the lane that works it.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.3/assets/icons/space.svg">
80
80
 
81
81
  ## In detail
82
82
 
83
83
  <details>
84
84
  <summary><b>What Jack does on his own, and what waits for you</b></summary>
85
85
 
86
- <img src="https://unpkg.com/jackherd@1.26.1/docs/lanes-dark.svg" alt="Jack prompts two lanes and asks you for decisions and approvals; the lanes send questions back as text; jh watch reads lane state, pull requests and checks and wakes Jack.">
86
+ <img src="https://unpkg.com/jackherd@1.26.3/docs/lanes-dark.svg" alt="Jack prompts two lanes and asks you for decisions and approvals; the lanes send questions back as text; jh watch reads lane state, pull requests and checks and wakes Jack.">
87
87
 
88
88
  | On his own | After your approval | Never |
89
89
  | --- | --- | --- |
@@ -118,7 +118,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
118
118
 
119
119
  A **plan** is Markdown with phases, checkbox tasks, a model and a review gate per phase, and under each phase heading one sentence on what is different afterwards and for whom. A **board** lists the lanes of one or more plans: which phases each lane builds, on which model, after what. A **lane** is one agent session in its own git worktree (a second checkout of your repository), and **Jack** is the session in the main checkout that runs `/jh-orchestrate` on a board.
120
120
 
121
- <img src="https://unpkg.com/jackherd@1.26.1/docs/skills-dark.svg" alt="Your own plan or an issue is adopted (Jack adds what is missing, you see the diff) and goes to /jh-orchestrate; /jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement, which your own skill can replace; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
121
+ <img src="https://unpkg.com/jackherd@1.26.3/docs/skills-dark.svg" alt="Your own plan or an issue is adopted (Jack adds what is missing, you see the diff) and goes to /jh-orchestrate; /jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement, which your own skill can replace; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
122
122
 
123
123
  | Skill | Does |
124
124
  | --- | --- |
package/dist/config.d.ts CHANGED
@@ -122,6 +122,7 @@ declare const jackherdConfigSchema: z.ZodObject<{
122
122
  }, z.core.$strict>>;
123
123
  lanes: z.ZodPrefault<z.ZodObject<{
124
124
  copy: z.ZodDefault<z.ZodArray<z.ZodString>>;
125
+ disposable: z.ZodDefault<z.ZodArray<z.ZodString>>;
125
126
  lockFile: z.ZodOptional<z.ZodString>;
126
127
  minFreeGb: z.ZodOptional<z.ZodNumber>;
127
128
  basePort: z.ZodOptional<z.ZodNumber>;
package/dist/config.js CHANGED
@@ -747,6 +747,8 @@ var jackherdConfigSchema = z3.strictObject({
747
747
  lanes: z3.strictObject({
748
748
  /** Untracked files copied into every new worktree; empty means the files `.worktreeinclude` lists. */
749
749
  copy: z3.array(laneCopyPath).default([]),
750
+ /** Ignored paths `jh lane close` deletes with a worktree without asking, in `.gitignore` syntax (`apps/web/tmp`); a pattern without a slash matches at any depth. Everything else ignored still refuses the close. */
751
+ disposable: z3.array(laneCopyPath).default([]),
750
752
  /** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand; detected from the repository when left out. */
751
753
  lockFile: lockFilePath.optional(),
752
754
  /** Free gigabytes the disk must hold before `jh lane space` lets a new lane install its dependencies; 5 when left out. */
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.26.1"
3
+ version = "1.26.3"
4
4
  min_herdr_version = "0.8.2"
5
5
  description = "Show the herd, the state of a board's lanes, in a herdr pane"
6
6
  platforms = ["linux", "macos"]
package/install.sh CHANGED
@@ -9,7 +9,7 @@ set -eu
9
9
 
10
10
  # The default is the version this file ships with. The release bump changes it together with
11
11
  # package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
12
- JACKHERD_VERSION="${JACKHERD_VERSION:-1.26.1}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.26.3}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -4,16 +4,16 @@ Read when a lane's status line says `plan finished` and names `jh lane close <la
4
4
  or when the user names a lane to close. Run both commands from the main checkout.
5
5
 
6
6
  1. `jh lane close <lane> --dry-run` checks and lists the steps; it changes nothing. It proves nothing is lost: a
7
- merged PR whose tree equals the branch (`git diff <branch-head> <merge-commit>` empty; a squash merge leaves the
8
- branch unmerged), no file that removing the worktree would delete, no agent at work in it. A refusal changes
7
+ merged PR whose tree equals the branch (`git diff <branch-head> <merge-commit>` empty), no file that removing the worktree would delete (dependencies, build output and `lanes.disposable` paths go with it; `.claude/handover/` is
8
+ copied to `.claude/handover/closed/` first), no agent at work in it. A refusal changes
9
9
  nothing: resolve each reason it prints (a named file goes to the user first, a working agent is waited for, a lock
10
- is lifted once its session ended, an unmerged branch is not closed) and repeat until it exits 0.
11
- A worktree on no branch closes when its head is on the base branch and only staged plan changes the base holds are left.
10
+ is lifted once its session ended) and repeat until it exits 0.
11
+ A worktree on no branch closes when its head is on the base branch and only staged plan changes remain.
12
12
  Handed-over service: [`backend.md`](backend.md).
13
- 2. `jh lane close <lane>` Run it without asking the user; its checks are the safeguard. It repeats the step 1 checks (resolve a refusal as in step 1) and runs the listed steps, one line each. After a failure,
13
+ 2. `jh lane close <lane>` Run it without asking the user; its checks are the safeguard. It repeats the step 1 checks and runs the listed steps, one line each. After a failure,
14
14
  run it again: with the worktree gone and the branch left, `jh lane close <lane> --branch <branch>` finishes the
15
- branch and the board; a lane closed in full says so and changes nothing.
15
+ branch and the board; a lane closed in full says so.
16
16
  3. The command leaves the rows marked `{{kit.word.laneClosed}} <date>` and the `{{kit.heading.history}}` row as a
17
17
  change in the main checkout's board, which the herd shows at once. It reaches `{{kit.baseBranch}}` bundled (§ 4), the
18
- marked rows in the same documentation PR as the history entry. Done when the board shows the marks and the history row. If `git ls-remote --heads origin <branch>` still lists the
19
- branch, ask the user before deleting it.
18
+ marked rows in the same documentation PR as the history entry. Done when the board shows the marks and the history row. The command deletes the branch on origin itself when it still
19
+ holds the merged head; otherwise it says why it stays.
@@ -25,4 +25,4 @@ A lane started before this skill, or a session to resume:
25
25
  `herdr worktree open --path <worktree> --label "<lane> :<port>" --no-focus`, then in its pane
26
26
  `claude --resume <session-id>`. Move a lane only between phases, when it is `done` or `idle`.
27
27
 
28
- Name the lane after the worktree directory; on `lane <name> not watched`, rename its row and agent to the `<dir>` the line names.
28
+ The watcher places a lane by its agent's cwd; `lane <name> not watched` means ambiguous: keep one `<name>` agent, in its own worktree.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.26.1",
3
+ "version": "1.26.3",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -147,6 +147,14 @@
147
147
  "pattern": "^(?![/-])(?!(?:.*\\/)?\\.\\.(?:\\/|$))[^'\\x00-\\x1f\\x7f]+$"
148
148
  }
149
149
  },
150
+ "disposable": {
151
+ "default": [],
152
+ "type": "array",
153
+ "items": {
154
+ "type": "string",
155
+ "pattern": "^(?![/-])(?!(?:.*\\/)?\\.\\.(?:\\/|$))[^'\\x00-\\x1f\\x7f]+$"
156
+ }
157
+ },
150
158
  "lockFile": {
151
159
  "type": "string",
152
160
  "pattern": "^(?![/-])(?!(?:.*\\/)?\\.\\.(?:\\/|$))[A-Za-z0-9._/-]+$"
@@ -1,10 +1,11 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, join, relative } from 'node:path'
3
3
  import { parseSprintLanes, resolvePlanPath, type SprintLane } from '../plan-format'
4
+ import { settingsPlacingLanes } from '../plans/lane-worktrees'
4
5
  import { type QuestionRecord } from '../plans/question-records'
5
6
  import { type SentFileRecord, sentLaneOf } from '../plans/sent-files'
6
- import { type PlansSettings } from '../plans/settings'
7
- import { laneDirectoryOf } from '../plans/unlisted-lanes'
7
+ import { type PlansSettings, worktreeOf } from '../plans/settings'
8
+ import { type LaneWorktrees, laneOfCwd } from '../plans/unlisted-lanes'
8
9
  import { readWaitingRecords } from '../plans/waiting-records'
9
10
  import { isStillWaiting } from '../plans/waiting-window'
10
11
  import type { LaneFacts } from './lane-brief'
@@ -14,8 +15,8 @@ const planHrefOf = ({ steps }: SprintLane): string | null =>
14
15
  steps.filter((step) => step.planHref !== null).at(-1)?.planHref ?? null
15
16
 
16
17
  /** The plan file as the lane's worktree has it (it ticks and writes the head there), else the main checkout's copy. */
17
- const planTextOf = (root: string, worktrees: string, lane: string, planPath: string): string | null => {
18
- const candidates = [join(worktrees, lane, planPath), join(root, planPath)]
18
+ const planTextOf = (root: string, worktree: string, planPath: string): string | null => {
19
+ const candidates = [join(worktree, planPath), join(root, planPath)]
19
20
  const found = candidates.find((path) => existsSync(path))
20
21
  return found === undefined ? null : readFileSync(found, 'utf8')
21
22
  }
@@ -25,10 +26,11 @@ const openQuestionsOf = (
25
26
  lane: string,
26
27
  worktreeDirectory: string,
27
28
  now: number,
29
+ placed: LaneWorktrees,
28
30
  ): number =>
29
31
  records.filter(
30
32
  ({ closedAt, askedAt, cwd }) =>
31
- isStillWaiting(closedAt, askedAt, now) && laneDirectoryOf(cwd, worktreeDirectory) === lane,
33
+ isStillWaiting(closedAt, askedAt, now) && laneOfCwd(cwd, worktreeDirectory, placed) === lane,
32
34
  ).length
33
35
 
34
36
  const openSendsOf = (
@@ -36,9 +38,11 @@ const openSendsOf = (
36
38
  lane: string,
37
39
  worktreeDirectory: string,
38
40
  now: number,
41
+ placed: LaneWorktrees,
39
42
  ): number =>
40
43
  records.filter(
41
- (record) => isStillWaiting(record.closedAt, record.sentAt, now) && sentLaneOf(record, worktreeDirectory) === lane,
44
+ (record) =>
45
+ isStillWaiting(record.closedAt, record.sentAt, now) && sentLaneOf(record, worktreeDirectory, placed) === lane,
42
46
  ).length
43
47
 
44
48
  /**
@@ -49,16 +53,24 @@ export const laneFactsOf = (root: string, settings: PlansSettings, boardFile: st
49
53
  const sprintDirectory = dirname(relative(root, boardFile))
50
54
  const worktreeDirectory = join(root, settings.worktreeDirectory)
51
55
  const records = readWaitingRecords(root)
52
- return parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.boardLanguage)
53
- .filter(({ closed }) => !closed)
54
- .map((lane) => {
55
- const href = planHrefOf(lane)
56
- return {
57
- name: lane.name,
58
- plan:
59
- href === null ? null : planTextOf(root, worktreeDirectory, lane.name, resolvePlanPath(sprintDirectory, href)),
60
- openQuestions: openQuestionsOf(records.questions, lane.name, worktreeDirectory, now),
61
- sentFiles: openSendsOf(records.sent, lane.name, worktreeDirectory, now),
62
- }
63
- })
56
+ const lanes = parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.boardLanguage).filter(
57
+ ({ closed }) => !closed,
58
+ )
59
+ const placed = settingsPlacingLanes(
60
+ root,
61
+ settings,
62
+ lanes.map(({ name }) => name),
63
+ )
64
+ return lanes.map((lane) => {
65
+ const href = planHrefOf(lane)
66
+ return {
67
+ name: lane.name,
68
+ plan:
69
+ href === null
70
+ ? null
71
+ : planTextOf(root, worktreeOf(root, placed, lane.name), resolvePlanPath(sprintDirectory, href)),
72
+ openQuestions: openQuestionsOf(records.questions, lane.name, worktreeDirectory, now, placed.laneWorktrees),
73
+ sentFiles: openSendsOf(records.sent, lane.name, worktreeDirectory, now, placed.laneWorktrees),
74
+ }
75
+ })
64
76
  }
@@ -603,6 +603,8 @@ export const jackherdConfigSchema = z.strictObject({
603
603
  .strictObject({
604
604
  /** Untracked files copied into every new worktree; empty means the files `.worktreeinclude` lists. */
605
605
  copy: z.array(laneCopyPath).default([]),
606
+ /** Ignored paths `jh lane close` deletes with a worktree without asking, in `.gitignore` syntax (`apps/web/tmp`); a pattern without a slash matches at any depth. Everything else ignored still refuses the close. */
607
+ disposable: z.array(laneCopyPath).default([]),
606
608
  /** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand; detected from the repository when left out. */
607
609
  lockFile: lockFilePath.optional(),
608
610
  /** Free gigabytes the disk must hold before `jh lane space` lets a new lane install its dependencies; 5 when left out. */
@@ -2,6 +2,7 @@ import { homedir } from 'node:os'
2
2
  import { basename, join } from 'node:path'
3
3
  import { CLAUDE_DIRECTORY } from '../claude/paths'
4
4
  import { mainCheckout, worktreeRoot } from '../plans/git'
5
+ import { settingsPlacingLanes } from '../plans/lane-worktrees'
5
6
  import { type PlansSettings, readSettings, worktreeOf } from '../plans/settings'
6
7
  import { type LaneAgent, laneStatusSourceFor } from '../terminal'
7
8
  import { AUTO_COMPACT_ENV, autoCompactWindow, compactsBeforeAlertNote, readClaudeSettings } from './auto-compact'
@@ -96,7 +97,8 @@ export const run = async (args: string[]): Promise<number> => {
96
97
  const options = parseContextOptions(args)
97
98
  const root = mainCheckout(process.cwd())
98
99
  const worktree = worktreeRoot(process.cwd())
99
- const settings = readSettings([worktree, root])
100
+ const read = readSettings([worktree, root])
101
+ const settings = options.lane ? settingsPlacingLanes(root, read, [options.lane]) : read
100
102
  if (options.usage) {
101
103
  printUsage(options, settings, root, worktree)
102
104
  return 0
@@ -27,8 +27,9 @@ export type StatusEntry = { code: string; path: string }
27
27
  /**
28
28
  * What git, GitHub and the terminal answered about one lane, gathered before anything is removed. `treeDiff` lists the
29
29
  * paths in which the branch head differs from the merge commit; `status` holds the worktree's status with ignored
30
- * files (`statusEntriesOf`); `setupFiles` the patterns of the lane setup's copy list; `handoverEntries` every
31
- * entry of the worktree's handover directory, which git shows only as the directory.
30
+ * files (`statusEntriesOf`); `setupFiles` the patterns of the lane setup's copy list; `disposablePaths` the patterns
31
+ * of ignored paths the project lets a close delete (`lanes.disposable`); `handoverEntries` every entry of the
32
+ * worktree's handover directory, which git shows only as the directory and which the close archives.
32
33
  */
33
34
  export type CloseFacts = {
34
35
  lane: string
@@ -42,7 +43,10 @@ export type CloseFacts = {
42
43
  treeDiff: readonly string[]
43
44
  status: readonly StatusEntry[]
44
45
  setupFiles: readonly string[]
46
+ disposablePaths: readonly string[]
45
47
  handoverEntries: readonly string[]
48
+ /** The commit the branch points to on origin, read live; null when the branch is gone there or none is named. */
49
+ remoteHead: string | null
46
50
  /** Whether the head of a worktree on no branch is an ancestor of the base branch on the remote. */
47
51
  headOnBase: boolean
48
52
  /** The staged paths of a worktree on no branch that the base branch on the remote holds with the same content. */
@@ -74,6 +78,7 @@ const BUILD_OUTPUT = [
74
78
  /** Build output only as a directory entry: a package's `bin/` that an ignore rule hides as a whole. */
75
79
  const BUILD_OUTPUT_DIRECTORY_ENTRIES = [new Bun.Glob('packages/*/bin')]
76
80
  const HANDOVER = claudePath(HANDOVER_DIRECTORY)
81
+ const SHORT_COMMIT_LENGTH = 9
77
82
 
78
83
  /** A change that is staged and nothing more: the worktree file equals the index. */
79
84
  const isStagedOnly = (code: string): boolean =>
@@ -81,22 +86,27 @@ const isStagedOnly = (code: string): boolean =>
81
86
 
82
87
  const withoutSlashes = (path: string): string => path.replace(/^\/+|\/+$/g, '')
83
88
 
84
- const isHandoverDirectory = (path: string): boolean => withoutSlashes(path) === HANDOVER
89
+ /** The handover directory or anything in it: the close archives it, so removing the worktree loses none of it. */
90
+ const isHandoverPath = (path: string): boolean => {
91
+ const target = withoutSlashes(path)
92
+ return target === HANDOVER || target.startsWith(`${HANDOVER}/`)
93
+ }
85
94
 
86
95
  /** The patterns of a `.gitignore`-style list that name files; comments, blanks and negations name none. */
87
96
  const patternsOf = (lines: readonly string[]): string[] =>
88
97
  lines.map((line) => line.trim()).filter((line) => line !== '' && !line.startsWith('#') && !line.startsWith('!'))
89
98
 
90
99
  /**
91
- * Whether `path` (relative to the worktree, a directory with its trailing slash) is one the lane setup copies in: a
92
- * pattern with an inner slash is anchored at the root, one without matches the name at any depth, as in `.gitignore`.
100
+ * Whether `path` (relative to the worktree, a directory with its trailing slash) is named by a `.gitignore`-style
101
+ * list, or lies in a directory it names: a pattern with an inner slash is anchored at the root, one without matches
102
+ * the name at any depth.
93
103
  */
94
- export const isSetupFile = (path: string, setupFiles: readonly string[]): boolean => {
104
+ export const matchesPatterns = (path: string, patterns: readonly string[]): boolean => {
95
105
  const target = withoutSlashes(path)
96
- return patternsOf(setupFiles).some((pattern) => {
106
+ return patternsOf(patterns).some((pattern) => {
97
107
  const bare = withoutSlashes(pattern)
98
108
  const glob = bare.includes('/') ? bare : `**/${bare}`
99
- return new Bun.Glob(glob).match(target) || new Bun.Glob(bare).match(target)
109
+ return [glob, bare, `${glob}/**`].some((candidate) => new Bun.Glob(candidate).match(target))
100
110
  })
101
111
  }
102
112
 
@@ -134,25 +144,51 @@ export const isBuildOutput = (path: string): boolean => {
134
144
 
135
145
  /**
136
146
  * The paths removing the worktree would delete for good: every tracked change and untracked file, and every ignored
137
- * path except dependencies (`node_modules` at any depth), the setup files, build output and the handover directory.
147
+ * path except dependencies (`node_modules` at any depth), the setup files, the project's disposable paths and build
148
+ * output. The handover directory is no loss either: the close archives it first.
138
149
  */
139
150
  export const lostPathsOf = (
140
151
  status: readonly StatusEntry[],
141
152
  setupFiles: readonly string[],
142
153
  baseHeldPaths: readonly string[] = [],
154
+ disposablePaths: readonly string[] = [],
143
155
  ): string[] => {
144
156
  const kept = (path: string) =>
145
- isDependencyPath(path) || isSetupFile(path, setupFiles) || isBuildOutput(path) || isHandoverDirectory(path)
157
+ isDependencyPath(path) ||
158
+ matchesPatterns(path, setupFiles) ||
159
+ matchesPatterns(path, disposablePaths) ||
160
+ isBuildOutput(path)
146
161
  const held = ({ code, path }: StatusEntry) => isStagedOnly(code) && baseHeldPaths.includes(path)
147
162
  return status
163
+ .filter(({ path }) => !isHandoverPath(path))
148
164
  .filter((entry) => (entry.code !== IGNORED_CODE || !kept(entry.path)) && !held(entry))
149
165
  .map(({ path }) => path)
150
166
  }
151
167
 
152
- /** The lost paths and the entries of the handover directory, each once: the directory stands for all of them. */
153
- const deletedPathsOf = ({ status, setupFiles, handoverEntries, baseHeldPaths }: CloseFacts): string[] => [
154
- ...new Set([...lostPathsOf(status, setupFiles, baseHeldPaths), ...handoverEntries]),
155
- ]
168
+ const deletedPathsOf = ({ status, setupFiles, baseHeldPaths, disposablePaths }: CloseFacts): string[] =>
169
+ lostPathsOf(status, setupFiles, baseHeldPaths, disposablePaths)
170
+
171
+ /** What closing does with the lane's branch on origin: delete it at the head the check saw, leave it with the reason, or nothing to do. */
172
+ export type RemoteBranchPlan =
173
+ | { kind: 'delete'; branch: string; head: string }
174
+ | { kind: 'keep'; branch: string; reason: string }
175
+ | { kind: 'none' }
176
+
177
+ /**
178
+ * The branch on origin goes only when it points to the head of the merged pull request, so nothing pushed after the
179
+ * merge is lost; otherwise it stays and the plan says why.
180
+ */
181
+ export const remoteBranchPlanOf = ({ branch, pullRequest, remoteHead }: CloseFacts): RemoteBranchPlan => {
182
+ if (branch === null || remoteHead === null) return { kind: 'none' }
183
+ if (pullRequest === null) return { kind: 'keep', branch, reason: 'it has no merged pull request' }
184
+ if (remoteHead !== pullRequest.headCommit)
185
+ return {
186
+ kind: 'keep',
187
+ branch,
188
+ reason: `origin has ${remoteHead.slice(0, SHORT_COMMIT_LENGTH)}, not the merged head ${pullRequest.headCommit.slice(0, SHORT_COMMIT_LENGTH)} of #${pullRequest.number}`,
189
+ }
190
+ return { kind: 'delete', branch, head: remoteHead }
191
+ }
156
192
 
157
193
  /** A lane the board marks cleaned up already may still have its worktree left over; only a lane it never named is refused. */
158
194
  const boardRefusals = ({ lane, boardLane }: CloseFacts): string[] =>
@@ -183,7 +219,7 @@ const lockRefusals = ({ lane, locked }: CloseFacts): string[] =>
183
219
  * Every reason not to close the lane, all at once; empty when nothing would be lost. Closing needs an open board row,
184
220
  * the worktree on a branch whose newest merged pull request left the same tree on the base (a squash merge leaves the
185
221
  * branch unmerged in git's eyes, so the trees are compared), nothing in the worktree beyond dependencies, the setup
186
- * files and build output (`isBuildOutput`), an empty handover directory, an agent that is gone or rests, and no lock
222
+ * files, the project's disposable paths and build output (`isBuildOutput`), an agent that is gone or rests, and no lock
187
223
  * on the worktree (a Claude Code session may still hold it).
188
224
  */
189
225
  export const closeRefusals = (facts: CloseFacts): string[] => {
@@ -1,10 +1,11 @@
1
- import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
1
+ import { cpSync, existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
2
2
  import { join, relative, resolve } from 'node:path'
3
3
  import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
4
4
  import { TERMINAL_ADAPTER } from '../config'
5
5
  import { terminalSafe } from '../plan-format/terminal-text'
6
6
  import { boardFor } from '../plans/board-pick'
7
7
  import { commitOf, mainCheckout, worktreeRoot } from '../plans/git'
8
+ import { settingsPlacingBoardLanes } from '../plans/lane-worktrees'
8
9
  import { type PlansSettings, readSettings, worktreeOf } from '../plans/settings'
9
10
  import { asksForHelp } from '../preview/options'
10
11
  import { laneStatusSourceFor } from '../terminal'
@@ -20,6 +21,7 @@ import {
20
21
  baseHeldPathsOf,
21
22
  mergedPullRequestOf,
22
23
  openWorkspaceOf,
24
+ remoteHeadOf,
23
25
  stagedPlanPathsOf,
24
26
  type WorktreeEntry,
25
27
  worktreeEntryOf,
@@ -179,19 +181,29 @@ const workspaceOf = (context: CloseContext): string | null =>
179
181
  const absentWorktreeFactsOf = (context: CloseContext, branch: string | null): Promise<WorktreeFacts> | WorktreeFacts =>
180
182
  branch === null ? ABSENT_WORKTREE : branchFactsOf(context, branch)
181
183
 
184
+ /** The commit the branch points to on origin, asked live; null when there is no branch to ask for or origin lists none. */
185
+ const remoteHeadFor = (context: CloseContext, branch: string | null): string | null =>
186
+ branch === null
187
+ ? null
188
+ : remoteHeadOf(runGit(context.root, ['ls-remote', '--heads', 'origin', `${BRANCH_REF}${branch}`]), branch)
189
+
182
190
  /** What git, GitHub and the terminal say about the lane now; throws when one of them cannot be asked. */
183
191
  const surveyOf = async (lane: string, branch: string | null, context: CloseContext): Promise<CloseSurvey> => {
184
192
  const entry = worktreeEntryOf(runGit(context.root, ['worktree', 'list', '--porcelain']), context.worktree)
185
193
  const present = entry !== null && existsSync(context.worktree)
186
- const facts: CloseFacts = {
194
+ const facts: Omit<CloseFacts, 'remoteHead'> = {
187
195
  lane,
188
196
  boardLane: boardLaneOf(readFileSync(context.boardFile, 'utf8'), lane, context.settings.boardLanguage),
189
197
  setupFiles: setupFilesOf(context.root),
198
+ disposablePaths: context.settings.disposablePaths,
190
199
  ...agentFactsOf(laneStatusSourceFor(context.settings.adapter).listAgents(), lane, context.worktree),
191
200
  runsInside: realPath(worktreeRoot(context.cwd)) === realPath(context.worktree),
192
201
  ...(present ? await worktreeFactsOf(context, entry) : await absentWorktreeFactsOf(context, branch)),
193
202
  }
194
- return { facts, workspace: present ? workspaceOf(context) : null }
203
+ return {
204
+ facts: { ...facts, remoteHead: remoteHeadFor(context, facts.branch) },
205
+ workspace: present ? workspaceOf(context) : null,
206
+ }
195
207
  }
196
208
 
197
209
  const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms))
@@ -211,7 +223,14 @@ const exitAgentIn =
211
223
  throw new Error(`the agent in pane ${pane} still runs after ${EXIT_WAIT_MS / 1000} s`)
212
224
  }
213
225
 
214
- /** Git removes the worktree and refuses on any change itself; herdr then only closes the emptied workspace. */
226
+ /** Copies the handover directory of the worktree below the main checkout; throws when the copy fails, so nothing is removed after it. */
227
+ const archiveHandoverIn =
228
+ (context: CloseContext) =>
229
+ (worktree: string, destination: string): void => {
230
+ cpSync(join(worktree, claudePath(HANDOVER_DIRECTORY)), join(context.root, destination), { recursive: true })
231
+ }
232
+
233
+ /** Git removes the worktree and refuses on any change itself (ignored files go with it, never needing `--force`); herdr then only closes the emptied workspace. */
215
234
  const removeWorktreeIn =
216
235
  (context: CloseContext) =>
217
236
  (worktree: string, workspace: string | null): void => {
@@ -220,6 +239,17 @@ const removeWorktreeIn =
220
239
  }
221
240
 
222
241
  /** Deletes the branch only while it points to `head`; git refuses when it moved since the check. */
242
+ const deleteRemoteBranchIn =
243
+ (context: CloseContext) =>
244
+ (branch: string, head: string): void => {
245
+ runGit(context.root, [
246
+ 'push',
247
+ 'origin',
248
+ `--force-with-lease=${BRANCH_REF}${branch}:${head}`,
249
+ `:${BRANCH_REF}${branch}`,
250
+ ])
251
+ }
252
+
223
253
  const deleteBranchIn =
224
254
  (context: CloseContext) =>
225
255
  (branch: string, head: string): void => {
@@ -229,7 +259,9 @@ const deleteBranchIn =
229
259
  const portsFor = (lane: string, branch: string | null, context: CloseContext): ClosePorts => ({
230
260
  survey: () => surveyOf(lane, branch, context),
231
261
  exitAgent: exitAgentIn(context),
262
+ archiveHandover: archiveHandoverIn(context),
232
263
  removeWorktree: removeWorktreeIn(context),
264
+ deleteRemoteBranch: deleteRemoteBranchIn(context),
233
265
  deleteBranch: deleteBranchIn(context),
234
266
  readBoard: () => readFileSync(context.boardFile, 'utf8'),
235
267
  writeBoard: (text) => writeFileSync(context.boardFile, text),
@@ -240,16 +272,17 @@ const portsFor = (lane: string, branch: string | null, context: CloseContext): C
240
272
  /** The main checkout, its settings, the lane's worktree from those settings, and the board, with the pick's notice. */
241
273
  const contextOf = (options: CloseOptions, cwd: string): { context: CloseContext; notice: string | null } => {
242
274
  const root = mainCheckout(cwd)
243
- const settings = readSettings([root, worktreeRoot(cwd)])
275
+ const settings = readSettings([worktreeRoot(cwd), root])
244
276
  const boardPath = options.boardPath === null ? null : resolve(cwd, options.boardPath)
245
277
  const board = boardFor(boardPath, [root], settings, () => [options.lane])
246
- const worktree = worktreeOf(root, settings, options.lane)
247
- return { context: { root, cwd, settings, worktree, boardFile: board.file }, notice: board.notice }
278
+ const placed = settingsPlacingBoardLanes(root, settings, board.file, options.lane)
279
+ const worktree = worktreeOf(root, placed, options.lane)
280
+ return { context: { root, cwd, settings: placed, worktree, boardFile: board.file }, notice: board.notice }
248
281
  }
249
282
 
250
283
  /**
251
284
  * `jh lane close <lane> [--dry-run] [--board <board.md>]`: closes a lane in one run once nothing would be lost, see
252
- * `closeLane`. The worktree's path comes from the settings and the lane's name, never from a reply. Exit 0 closed or
285
+ * `closeLane`. The worktree's path comes from the lane's name, or from the one existing directory below the worktree directory that the lane's agent runs in when no directory carries the name; never from a reply. Exit 0 closed or
253
286
  * dry run, 1 refused or failed.
254
287
  */
255
288
  export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
@@ -62,6 +62,15 @@ export const mergedPullRequestOf = (
62
62
  return merged.sort((left, right) => right.number - left.number)[0] ?? null
63
63
  }
64
64
 
65
+ /** The commit `branch` points to in the output of `git ls-remote --heads origin`; null when the remote lists no such branch. */
66
+ export const remoteHeadOf = (lsRemote: string, branch: string): string | null => {
67
+ const entry = lsRemote
68
+ .split('\n')
69
+ .map((line) => line.split('\t'))
70
+ .find(([, ref]) => ref === `${BRANCH_REF}${branch}`)
71
+ return entry !== undefined && isObjectId(entry[0]) ? entry[0] : null
72
+ }
73
+
65
74
  const isInside = (cwd: string | null, worktree: string): boolean =>
66
75
  cwd !== null && (cwd === worktree || cwd.startsWith(`${worktree}${sep}`))
67
76