jackherd 1.20.0 → 1.22.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  What changed for people who use jackherd, newest first.
4
4
 
5
+ ## 1.22.0
6
+
7
+ - **The approval gate no longer asks about a Bash call that only reads.** In a lane, and for a subagent of the coordinator, a Bash command passes at once, with no request and no question, when every simple command in it (across `;`, `&&`, `||`, `|` and newlines) is a reading command: `cd` inside the working directory, `echo`, `printf`, `cat`, `head`, `tail` (not `-f`), `wc`, `ls`, `grep`, `rg`, `sed -n` with a print script, `git grep`/`show`/`log`/`diff`/`status`/`ls-files`/`ls-tree`/`rev-parse`/`merge-base`/`blame`, `find` without `-exec`/`-delete`/`-fprint*`, `sort`, `uniq`, `cut` and `true`. A plain `NAME=value` assignment and `$NAME` as a command word are followed. Anything the check does not model (command substitution, a redirection to a file, a heredoc, a subshell, an unknown command or variable) keeps today's question. A word with an unquoted `*`, `?`, `[`, `]`, `{` or `}` is not passed either, since the shell would expand it after the check, and an abbreviated long option (`sort --outp=out`) counts as the option it abbreviates. Run `jh sync` to get the hook.
8
+
9
+ ## 1.21.0
10
+
11
+ - **`jh init` and `jh sync` prepare the main checkout for the herdr plugin `zerodice0.worktree-bootstrap`.** When herdr lists the plugin as enabled, they write `.herdr/worktree-copy.list` from `lanes.copy`, `.herdr/worktree-setup.json` with `commands.install` (run with the Node version of `.nvmrc` when the checkout has one) and `/.herdr/` into the git exclude file. A second run rewrites nothing. A `lanes.copy` path git does not ignore is named in a warning, since the plugin skips it. Without herdr or the plugin nothing is written. The orchestrate lane setup also names the plugin's `sync` for a worktree that existed before. Run `jh sync` to get the skill text.
12
+
5
13
  ## 1.20.0
6
14
 
7
15
  - **`jh lane port` picks a lane's dev-server port.** It prints the lowest port from 3100 that nothing listens on and that no running lane holds in the port column of the board (`--board <board.md>` names the board, else it is picked as for `jh herd`). Rows of cleaned-up lanes reserve nothing, so ports no longer climb. `lanes.basePort` in `.jackherd/config.json` moves the start. Run `jh sync` to get the skill 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.20.0/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.22.0/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.20.0/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.22.0/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.20.0/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.22.0/assets/icons/space.svg">
19
19
 
20
20
  <p>
21
- <img src="https://unpkg.com/jackherd@1.20.0/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.22.0/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.20.0/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.22.0/assets/icons/space.svg">
24
24
  </p>
25
25
 
26
- <p><img src="https://unpkg.com/jackherd@1.20.0/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.22.0/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.20.0/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.22.0/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.20.0/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.22.0/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.20.0/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.22.0/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.20.0/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.22.0/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.20.0/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.22.0/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.20.0/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.22.0/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.20.0/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.20.0/assets/icons/space.svg"></p>
40
+ <p><img src="https://unpkg.com/jackherd@1.22.0/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.22.0/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.20.0/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.22.0/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.20.0/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.22.0/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.20.0/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.22.0/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.20.0/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.22.0/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. 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.20.0/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.22.0/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
  | --- | --- |
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.20.0"
3
+ version = "1.22.0"
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.20.0}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.22.0}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -0,0 +1,310 @@
1
+ import { realpathSync } from 'node:fs'
2
+ import { isAbsolute, relative, resolve } from 'node:path'
3
+
4
+ type Token = { kind: 'word'; value: string } | { kind: 'variable'; name: string } | { kind: 'separator' }
5
+
6
+ type PartialWord = { value: string; quoted: boolean }
7
+
8
+ type Step =
9
+ | { kind: 'text'; value: string; quoted: boolean; next: number }
10
+ | { kind: 'blank'; next: number }
11
+ | { kind: 'separator'; next: number }
12
+ | { kind: 'variable'; name: string; next: number }
13
+ | { kind: 'redirect'; next: number }
14
+
15
+ type Quoted = { value: string; next: number }
16
+
17
+ /** The directory the check starts in, and the one `cd` has reached since. */
18
+ type Position = { root: string; current: string }
19
+
20
+ type Rule = (args: string[], position: Position) => boolean
21
+
22
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*/
23
+ const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/
24
+ const BOUNDARY = /^(?:$|[ \t\n;&|])/
25
+ const ALLOWED_REDIRECT = /^>(?:&1|[ \t]*\/dev\/null(?=$|[ \t\n;&|]))/
26
+ const SAFE_EXPANSION = /^[\w./:=,@%+-]+(?: +[\w./:=,@%+-]+)*$/
27
+ const SECRET_NAME = /^\.env|\.(?:pem|key)$|^id_(?:rsa|ed25519)|^Pulumi\..*\.ya?ml$|credentials|secret/i
28
+ const SECRET_DIRECTORY = /(?:^|\/)(?:\.ssh|\.aws|\.gnupg|\.pulumi|\.config\/gh)(?:\/|$)/
29
+ const FORBIDDEN_NAME =
30
+ /^(?:PATH|IFS|HOME|CDPATH|ENV|BASH.*|SHELLOPTS|PS4|PROMPT_COMMAND|GIT_.*|LD_.*|.*PAGER|EDITOR|LESS.*)$/
31
+ const UNQUOTED_FORBIDDEN = new Set(['`', '(', ')', '<', '\\', '\r', '*', '?', '[', ']', '{', '}'])
32
+
33
+ const GIT_READ_SUBCOMMANDS = new Set([
34
+ 'grep',
35
+ 'show',
36
+ 'log',
37
+ 'diff',
38
+ 'status',
39
+ 'ls-files',
40
+ 'ls-tree',
41
+ 'rev-parse',
42
+ 'merge-base',
43
+ 'blame',
44
+ ])
45
+ const FIND_WRITING_PRIMARIES = new Set([
46
+ '-exec',
47
+ '-execdir',
48
+ '-ok',
49
+ '-okdir',
50
+ '-delete',
51
+ '-fprint',
52
+ '-fprint0',
53
+ '-fprintf',
54
+ '-fls',
55
+ ])
56
+ const SED_SCRIPT = /^(?:(?:\d+|\$|\/[^/\\]*\/)(?:,(?:\d+|\$|\+\d+|\/[^/\\]*\/))?)[p=]$/
57
+ const SED_FLAGS = /^-[nEr]+$/
58
+ const SED_FLAGS_WITH_SCRIPT = /^-[nEr]*e$/
59
+
60
+ const singleQuoted = (text: string, start: number): Quoted | null => {
61
+ const end = text.indexOf("'", start + 1)
62
+ return end === -1 ? null : { value: text.slice(start + 1, end), next: end + 1 }
63
+ }
64
+
65
+ const doubleQuoted = (text: string, start: number): Quoted | null => {
66
+ const end = text.indexOf('"', start + 1)
67
+ if (end === -1) return null
68
+ const value = text.slice(start + 1, end)
69
+ return /[$`\\]/.test(value) ? null : { value, next: end + 1 }
70
+ }
71
+
72
+ /** `$NAME` or `${NAME}`, only as a whole word; anything else after a dollar sign is refused. */
73
+ const expansionAt = (text: string, start: number, atWordStart: boolean): Step | null => {
74
+ if (!atWordStart) return null
75
+ const braced = text[start + 1] === '{'
76
+ const nameStart = start + (braced ? 2 : 1)
77
+ const name = NAME.exec(text.slice(nameStart))?.[0]
78
+ if (name === undefined) return null
79
+ const end = nameStart + name.length
80
+ if (braced && text[end] !== '}') return null
81
+ const next = braced ? end + 1 : end
82
+ return BOUNDARY.test(text.slice(next)) ? { kind: 'variable', name, next } : null
83
+ }
84
+
85
+ /** A redirection to the null device or `2>&1`; the word before it may only be a file descriptor. */
86
+ const redirectAt = (text: string, start: number, word: PartialWord | null): Step | null => {
87
+ if (word !== null && (word.quoted || (word.value !== '1' && word.value !== '2'))) return null
88
+ const matched = ALLOWED_REDIRECT.exec(text.slice(start))
89
+ if (matched === null) return null
90
+ return { kind: 'redirect', next: start + matched[0].length }
91
+ }
92
+
93
+ const separatorAt = (text: string, start: number): Step | null => {
94
+ const char = text[start]
95
+ const double = text[start + 1] === char
96
+ if (char === '&' && !double) return null
97
+ return { kind: 'separator', next: start + (double ? 2 : 1) }
98
+ }
99
+
100
+ const quotedStep = (text: string, start: number): Step | null => {
101
+ const quoted = text[start] === "'" ? singleQuoted(text, start) : doubleQuoted(text, start)
102
+ return quoted === null ? null : { kind: 'text', value: quoted.value, quoted: true, next: quoted.next }
103
+ }
104
+
105
+ const stepAt = (text: string, start: number, word: PartialWord | null): Step | null => {
106
+ const char = text[start] as string
107
+ if (char === ' ' || char === '\t') return { kind: 'blank', next: start + 1 }
108
+ if (char === '\n' || char === ';') return { kind: 'separator', next: start + 1 }
109
+ if (char === '&' || char === '|') return separatorAt(text, start)
110
+ if (char === "'" || char === '"') return quotedStep(text, start)
111
+ if (char === '$') return expansionAt(text, start, word === null)
112
+ if (char === '>') return redirectAt(text, start, word)
113
+ if (UNQUOTED_FORBIDDEN.has(char) || (char === '#' && word === null)) return null
114
+ return { kind: 'text', value: char, quoted: false, next: start + 1 }
115
+ }
116
+
117
+ /** Splits the command into words, variable words and separators; null for any construct the check does not model. */
118
+ const tokenize = (text: string): Token[] | null => {
119
+ const tokens: Token[] = []
120
+ let word = null as PartialWord | null
121
+ const flush = () => {
122
+ if (word !== null) tokens.push({ kind: 'word', value: word.value })
123
+ word = null
124
+ }
125
+ let position = 0
126
+ while (position < text.length) {
127
+ const step = stepAt(text, position, word)
128
+ if (step === null) return null
129
+ position = step.next
130
+ if (step.kind === 'text') {
131
+ word = { value: (word?.value ?? '') + step.value, quoted: (word?.quoted ?? false) || step.quoted }
132
+ continue
133
+ }
134
+ if (step.kind === 'redirect') {
135
+ word = null
136
+ continue
137
+ }
138
+ flush()
139
+ if (step.kind === 'separator') tokens.push({ kind: 'separator' })
140
+ if (step.kind === 'variable') tokens.push({ kind: 'variable', name: step.name })
141
+ }
142
+ flush()
143
+ return tokens
144
+ }
145
+
146
+ const splitCommands = (tokens: Token[]): Token[][] => {
147
+ const commands: Token[][] = [[]]
148
+ for (const token of tokens) {
149
+ if (token.kind === 'separator') commands.push([])
150
+ else commands[commands.length - 1]?.push(token)
151
+ }
152
+ return commands.filter((command) => command.length > 0)
153
+ }
154
+
155
+ /** Whether an option is one of the long options, or an abbreviation of one: GNU option parsing takes any unambiguous prefix. */
156
+ const hasLong = (args: string[], names: string[]): boolean =>
157
+ args.some((arg) => {
158
+ const option = arg.split('=')[0] as string
159
+ return option.length > 2 && names.some((name) => name.startsWith(option))
160
+ })
161
+
162
+ /** Whether a single-dash option carries one of the letters; a cluster such as `-nO` counts, a value starting with a dash too. */
163
+ const hasShort = (args: string[], letters: string): boolean =>
164
+ args.some((arg) => /^-[^-]/.test(arg) && [...letters].some((letter) => arg.includes(letter)))
165
+
166
+ const isInside = (root: string, target: string): boolean => {
167
+ const path = relative(root, target)
168
+ return path !== '..' && !path.startsWith('../') && !isAbsolute(path)
169
+ }
170
+
171
+ const realPathOrNull = (path: string): string | null => {
172
+ try {
173
+ return realpathSync(path)
174
+ } catch {
175
+ return null
176
+ }
177
+ }
178
+
179
+ /** `cd` into an existing directory that, once symbolic links are followed, still lies inside the root. */
180
+ const cdRule: Rule = (args, position) => {
181
+ const [target] = args
182
+ if (args.length !== 1 || target === undefined || target.startsWith('-') || target.startsWith('~')) return false
183
+ const root = realPathOrNull(position.root)
184
+ const next = realPathOrNull(resolve(position.current, target))
185
+ if (root === null || next === null || !isInside(root, next)) return false
186
+ position.current = next
187
+ return true
188
+ }
189
+
190
+ const isSedFlag = (arg: string): boolean => arg === '--quiet' || arg === '--silent' || SED_FLAGS.test(arg)
191
+
192
+ /** The scripts of a `sed` call, from `-e` or the first plain argument; null for an option the check does not know. */
193
+ const sedScripts = (args: string[]): string[] | null => {
194
+ const scripts: string[] = []
195
+ let index = 0
196
+ while (index < args.length) {
197
+ const arg = args[index++] as string
198
+ if (SED_FLAGS_WITH_SCRIPT.test(arg)) {
199
+ const script = args[index++]
200
+ if (script === undefined) return null
201
+ scripts.push(script)
202
+ } else if (arg.startsWith('-') && !isSedFlag(arg)) return null
203
+ else if (!arg.startsWith('-') && scripts.length === 0) scripts.push(arg)
204
+ }
205
+ return scripts
206
+ }
207
+
208
+ const isSedQuiet = (args: string[]): boolean =>
209
+ args.some((arg) => (isSedFlag(arg) || SED_FLAGS_WITH_SCRIPT.test(arg)) && (arg.startsWith('--') || arg.includes('n')))
210
+
211
+ const sedRule: Rule = (args) => {
212
+ const scripts = sedScripts(args)
213
+ return (
214
+ scripts !== null && isSedQuiet(args) && scripts.length > 0 && scripts.every((script) => SED_SCRIPT.test(script))
215
+ )
216
+ }
217
+
218
+ const gitRule: Rule = ([subcommand = '', ...rest]) =>
219
+ GIT_READ_SUBCOMMANDS.has(subcommand) &&
220
+ !hasLong(rest, ['--output', '--ext-diff', '--open-files-in-pager']) &&
221
+ !hasShort(rest, 'O')
222
+
223
+ const RULES: Record<string, Rule> = {
224
+ cd: cdRule,
225
+ true: () => true,
226
+ echo: () => true,
227
+ printf: () => true,
228
+ cat: () => true,
229
+ head: () => true,
230
+ wc: () => true,
231
+ ls: () => true,
232
+ grep: () => true,
233
+ cut: () => true,
234
+ tail: (args) => !hasShort(args, 'fF') && !hasLong(args, ['--follow']),
235
+ sort: (args) => !hasShort(args, 'o') && !hasLong(args, ['--output', '--compress-program']),
236
+ uniq: (args) => args.filter((arg) => !arg.startsWith('-')).length <= 1,
237
+ rg: (args) => !hasShort(args, 'z') && !hasLong(args, ['--pre', '--hostname-bin', '--search-zip']),
238
+ find: (args) => !args.some((arg) => FIND_WRITING_PRIMARIES.has(arg)),
239
+ sed: sedRule,
240
+ git: gitRule,
241
+ }
242
+
243
+ /** The assignment `NAME=value` as a whole command: records it, or null when the name could change how commands run. */
244
+ const assign = (word: string, variables: Map<string, string>): boolean => {
245
+ const matched = ASSIGNMENT.exec(word)
246
+ if (matched === null || FORBIDDEN_NAME.test(matched[1] as string)) return false
247
+ variables.set(matched[1] as string, matched[2] as string)
248
+ return true
249
+ }
250
+
251
+ /** The words of the command with a leading `$NAME` replaced by its recorded value; null when anything else expands. */
252
+ const wordsOf = (command: Token[], variables: Map<string, string>): string[] | null => {
253
+ const [first, ...rest] = command
254
+ if (rest.some((token) => token.kind !== 'word')) return null
255
+ const words = rest.map((token) => (token as { value: string }).value)
256
+ if (first?.kind === 'word') return [first.value, ...words]
257
+ const value = first?.kind === 'variable' ? variables.get(first.name) : undefined
258
+ return value !== undefined && SAFE_EXPANSION.test(value) ? [...value.split(' ').filter(Boolean), ...words] : null
259
+ }
260
+
261
+ const isAssignmentCommand = (command: Token[]): boolean => {
262
+ const [first] = command
263
+ return command.length === 1 && first?.kind === 'word' && ASSIGNMENT.test(first.value)
264
+ }
265
+
266
+ /** The arguments that name a place to read, without the search pattern of `grep`, `rg` and `git grep`. */
267
+ const pathArguments = (name: string, args: string[]): string[] => {
268
+ const isGitGrep = name === 'git' && args[0] === 'grep'
269
+ if (name !== 'grep' && name !== 'rg' && !isGitGrep) return args
270
+ const search = isGitGrep ? args.slice(1) : args
271
+ const kept: string[] = []
272
+ let patternGiven = search.some((arg) => arg === '-e' || arg === '--regexp')
273
+ let skipNext = false
274
+ for (const arg of search) {
275
+ const isPatternFlag = arg === '-e' || arg === '--regexp'
276
+ const isPositionalPattern = !patternGiven && !arg.startsWith('-')
277
+ patternGiven = patternGiven || isPositionalPattern
278
+ if (!skipNext && !isPatternFlag && !isPositionalPattern) kept.push(arg)
279
+ skipNext = isPatternFlag
280
+ }
281
+ return isGitGrep ? [...args.slice(0, 1), ...kept] : kept
282
+ }
283
+
284
+ /** Whether an argument names a file that tends to hold secrets, also behind `--option=`, `rev:` or a glob. */
285
+ const namesSecret = (argument: string): boolean => {
286
+ const path = argument.replace(/[:=]/g, '/')
287
+ return SECRET_DIRECTORY.test(path) || path.split('/').some((part) => SECRET_NAME.test(part))
288
+ }
289
+
290
+ const isReadOnlyCommand = (command: Token[], variables: Map<string, string>, position: Position): boolean => {
291
+ if (isAssignmentCommand(command)) return assign((command[0] as { value: string }).value, variables)
292
+ const words = wordsOf(command, variables)
293
+ const [name = '', ...args] = words ?? []
294
+ const rule = Object.hasOwn(RULES, name) ? RULES[name] : undefined
295
+ return rule !== undefined && !pathArguments(name, args).some(namesSecret) && rule(args, position)
296
+ }
297
+
298
+ /**
299
+ * Whether a Bash command only reads: every simple command, across `;`, `&&`, `||`, `|` and newlines, is an allowlisted
300
+ * reading command with arguments that cannot write or run something else. Conservative: a construct this check does not
301
+ * model (substitution, redirection to a file, heredoc, subshell, unknown variable or command) answers false. `cd` may
302
+ * only reach a directory inside `cwd`; symbolic links are not followed, so a link inside `cwd` can still lead out.
303
+ */
304
+ export const isReadOnlyShellCommand = (command: string, cwd: string): boolean => {
305
+ const tokens = tokenize(command)
306
+ if (tokens === null) return false
307
+ const variables = new Map<string, string>()
308
+ const position: Position = { root: resolve(cwd), current: resolve(cwd) }
309
+ return splitCommands(tokens).every((simple) => isReadOnlyCommand(simple, variables, position))
310
+ }
@@ -1,10 +1,12 @@
1
1
  import { ASK_USER_QUESTION_TOOL } from '../shared/dialog-answers'
2
2
  import { isFresh, MS_PER_MINUTE } from '../shared/fresh'
3
+ import { isRecord } from '../shared/json'
3
4
  import { isCoordinatorAgent, laneOfAgent } from '../shared/lane'
4
5
  import type { PaneAgent } from '../shared/pane-agent'
5
6
  import { type StandingLabels, standingGrants } from '../shared/standing-mark'
6
7
  import { gateDecision, type GateLabels } from './decision'
7
8
  import { type ApprovalRequest, callHash, type GateCall, isStorableInput, newRequest, requestId } from './request'
9
+ import { isReadOnlyShellCommand } from './read-only-shell'
8
10
  import { type GateSettings, gateSettingsOf } from './settings'
9
11
  import { queryOf, type StandingHistoryEntry } from './standing-history'
10
12
  import { ENVIRONMENT_KIND, standingSqlDecision } from './standing-sql'
@@ -154,6 +156,10 @@ const awaitDecision = async (
154
156
  /** A question to the user asks for an answer, not for a permission: the lane asks the coordinator in its text instead. */
155
157
  const isHeld = ({ tool, input }: ClientCall): boolean => tool !== ASK_USER_QUESTION_TOOL && isStorableInput(input)
156
158
 
159
+ /** A Bash call whose command provably only reads passes at once; nobody is asked about it. */
160
+ const isReadOnlyBash = ({ tool, input, cwd }: ClientCall): boolean =>
161
+ tool === 'Bash' && isRecord(input) && typeof input.command === 'string' && isReadOnlyShellCommand(input.command, cwd)
162
+
157
163
  /**
158
164
  * What holds the call: the lane of the pane's agent, else, for a subagent of the coordinator, its own name. The
159
165
  * coordinator's session is the one the hook was called for, and the agent of the pane started in the main checkout
@@ -193,6 +199,7 @@ export const runGate = async (
193
199
  const settings = gateSettingsOf(dependencies.config(main))
194
200
  const lane = holderOf(clientCall, agent, main, settings.worktreeDirectory, dependencies)
195
201
  if (!lane) return null
202
+ if (isReadOnlyBash(clientCall)) return { kind: 'allow' }
196
203
  const { cwd, tool, input } = clientCall
197
204
  const request = openRequest(main, { lane, cwd, tool, input }, dependencies)
198
205
  return await awaitDecision(main, request, texts, settings, dependencies)
@@ -16,6 +16,11 @@ changes nothing. Decide by what you find:
16
16
  marker of step 4; that command is the check, not a look for a directory such as `node_modules/<package>`.
17
17
  If the command fails, do the step below yourself;
18
18
  if that fails too, report it.
19
+ - **Plugin enabled, but its hook never ran for this worktree** (taken over, or made before the plugin was set
20
+ up): inside the worktree, run
21
+ `env -u HERDR_PLUGIN_CONTEXT_JSON -u HERDR_PLUGIN_EVENT_JSON python3 <plugin_root>/worktree_bootstrap.py sync`
22
+ (`plugin_root` is in `herdr plugin list --json`; without `env -u` the script takes the context workspace).
23
+ It copies the env files only: then `cmp -s` per file, and step 4 stays.
19
24
  - **Plugin missing, disabled, or its file absent:** do the step as written below.
20
25
 
21
26
  ## Step 3: env files
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -57,7 +57,7 @@ export const KIT_SKILL_TARGETS: Readonly<Record<string, number>> = {
57
57
  '.claude/skills/orchestrate/references/lane-handover.md': 360,
58
58
  // Ending the agent names `herdr agent prompt <lane> /exit`, the form the merge guard passes (+25).
59
59
  '.claude/skills/orchestrate/references/lane-session.md': 285,
60
- '.claude/skills/orchestrate/references/lane-setup.md': 380,
60
+ '.claude/skills/orchestrate/references/lane-setup.md': 440,
61
61
  // Open up: an adopted plan is committed like a missing one, and a tracked copy is restored after the merge.
62
62
  // Core cut: the question of § 2 step 0 (the adoption's diff round, the approval it is, `<topic>`, a no) (+60).
63
63
  // Prose cut, round 2: the question itself moved here from § 2 step 0; measured size plus 5 %.
@@ -10,6 +10,7 @@ import { type KitLocation, locateKit, readKitSource } from './kit-source'
10
10
  import { driftExitCodeOf, exitCodeOf, formatReport, SYNC_EXIT } from './report'
11
11
  import type { SyncResult } from './sync-result'
12
12
  import { detectTools, INSTALLERS, type InstallerName, spawnTool, type ToolPresence } from './tools'
13
+ import { bootstrapHerdrWorktreesFrom } from './worktree-bootstrap'
13
14
 
14
15
  export type CommandContext = {
15
16
  projectRoot: string
@@ -21,6 +22,8 @@ export type CommandContext = {
21
22
  log: (line: string) => void
22
23
  /** Hooks the herdr plugin in and returns the line to print, if any. Must not throw: a failure is a line, not an error. */
23
24
  linkHerdrPlugin: (roots: { packageRoot: string; projectRoot: string }) => string | null
25
+ /** Prepares the main checkout for the herdr worktree plugin and returns the lines to print; none without herdr or the plugin. Must not throw. */
26
+ bootstrapWorktrees: (projectRoot: string) => string[]
24
27
  /** A yes/no question to the user; null when there is no terminal to ask in. */
25
28
  ask: (question: string) => Promise<boolean | null>
26
29
  /** A choice among fixed answers; Enter takes `fallback`; null when there is no terminal to ask in. */
@@ -96,6 +99,7 @@ export const defaultContext = (): CommandContext => ({
96
99
  locate: () => locateKit(),
97
100
  log: (line) => console.log(line),
98
101
  linkHerdrPlugin: linkHerdrPluginFrom,
102
+ bootstrapWorktrees: bootstrapHerdrWorktreesFrom,
99
103
  ask: askOnTerminal,
100
104
  choose: chooseOnTerminal,
101
105
  detectTools: () => detectTools(process.env, process.platform, spawnTool(process.env)),
@@ -111,7 +111,8 @@ const syncProject = (context: CommandContext, accept: string[], dryRun: boolean)
111
111
  const foreignLines = foreignSkillLines(result, readForeignSkills(context.projectRoot))
112
112
  const foreign = foreignLines.length > 0 ? [foreignLines.join('\n')] : []
113
113
  const dryRunNote = dryRun ? [`${CLI_NAME} sync ${CHECK_FLAG}: nothing is written`] : []
114
- return { notes: [...dryRunNote, ...foreign], result }
114
+ const bootstrap = dryRun ? [] : context.bootstrapWorktrees(context.projectRoot)
115
+ return { notes: [...dryRunNote, ...foreign, ...bootstrap], result }
115
116
  },
116
117
  dryRun,
117
118
  )
@@ -201,7 +201,11 @@ const initProjectCommand = async (context: CommandContext, answer: boolean | und
201
201
  })
202
202
  finished = { init, version: kit.version }
203
203
  return {
204
- notes: [...configNotes(init.configCreated, PROJECT_CONFIG_PATH, setup), ...notes],
204
+ notes: [
205
+ ...configNotes(init.configCreated, PROJECT_CONFIG_PATH, setup),
206
+ ...notes,
207
+ ...context.bootstrapWorktrees(context.projectRoot),
208
+ ],
205
209
  result: init.sync,
206
210
  }
207
211
  })
@@ -0,0 +1,150 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { readResolvedConfig } from '../config/read-config'
4
+ import { mainCheckoutOrNull } from '../plans/git'
5
+ import { CLI_NAME } from '../version/release'
6
+ import { herdrBinary, type RunTool, spawnTool } from './tools'
7
+
8
+ /** The herdr plugin that copies files into a new worktree and runs a setup command there. */
9
+ export const BOOTSTRAP_PLUGIN_ID = 'zerodice0.worktree-bootstrap'
10
+
11
+ export const BOOTSTRAP_COPY_LIST = '.herdr/worktree-copy.list'
12
+
13
+ export const BOOTSTRAP_SETUP = '.herdr/worktree-setup.json'
14
+
15
+ /** The line `.git/info/exclude` gets: the plugin's files belong to this machine, not to the repository. */
16
+ export const BOOTSTRAP_EXCLUDE_LINE = '/.herdr/'
17
+
18
+ const NODE_VERSION_FILE = '.nvmrc'
19
+
20
+ const SETUP_TIMEOUT_SECONDS = 900
21
+
22
+ const COPY_LIST_HEADER = `# Generated by ${CLI_NAME} from lanes.copy in .jackherd/config.json; change the config, not this file.`
23
+
24
+ export type BootstrapInput = {
25
+ /** The main checkout: the plugin reads its files there. */
26
+ mainRoot: string
27
+ /** `lanes.copy` of the config. */
28
+ copy: readonly string[]
29
+ /** `commands.install` of the config, undefined when the project has none. */
30
+ install: string | undefined
31
+ herdr: string
32
+ run: RunTool
33
+ }
34
+
35
+ /** Whether a `herdr plugin list --json` answer lists the bootstrap plugin as enabled; false for any other shape. */
36
+ export const isBootstrapPluginEnabled = (json: string): boolean => {
37
+ try {
38
+ const plugins = (JSON.parse(json) as { result?: { plugins?: unknown } }).result?.plugins
39
+ return (
40
+ Array.isArray(plugins) &&
41
+ plugins.some((plugin) => plugin?.plugin_id === BOOTSTRAP_PLUGIN_ID && plugin.enabled === true)
42
+ )
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ /** The plugin's copy list: one path per line below a header comment. */
49
+ export const copyListContent = (paths: readonly string[]): string => `${[COPY_LIST_HEADER, ...paths].join('\n')}\n`
50
+
51
+ /** The install command run with the Node version `.nvmrc` names; assumes nvm in `$HOME/.nvm`. */
52
+ export const withNodeVersion = (install: string): string => `. "$HOME/.nvm/nvm.sh" && nvm use >/dev/null && ${install}`
53
+
54
+ /** The plugin's setup file: the one command that installs a new worktree's dependencies. */
55
+ export const setupContent = (script: string): string =>
56
+ `${JSON.stringify(
57
+ {
58
+ version: 1,
59
+ commands: [
60
+ { name: 'Install dependencies', argv: ['bash', '-c', script], timeout_seconds: SETUP_TIMEOUT_SECONDS },
61
+ ],
62
+ },
63
+ null,
64
+ 2,
65
+ )}\n`
66
+
67
+ /** The exclude file with `line` added at its end, or null when it already holds that line. */
68
+ export const withExcludeLine = (current: string, line: string): string | null => {
69
+ if (current.split('\n').some((each) => each.trim() === line)) return null
70
+ const separator = current === '' || current.endsWith('\n') ? '' : '\n'
71
+ return `${current}${separator}${line}\n`
72
+ }
73
+
74
+ /** The warning for copy paths git does not ignore, which the plugin skips; null when there are none. */
75
+ export const notIgnoredWarning = (paths: readonly string[]): string | null =>
76
+ paths.length === 0
77
+ ? null
78
+ : `The herdr plugin ${BOOTSTRAP_PLUGIN_ID} copies only git-ignored paths and skips: ${paths.join(', ')}. Ignore them or take them off lanes.copy.`
79
+
80
+ const readOrEmpty = (path: string): string => (existsSync(path) ? readFileSync(path, 'utf8') : '')
81
+
82
+ /** Writes `content` unless the file already holds it; true when it wrote. */
83
+ const writeIfChanged = (path: string, content: string): boolean => {
84
+ if (readOrEmpty(path) === content) return false
85
+ mkdirSync(dirname(path), { recursive: true })
86
+ writeFileSync(path, content)
87
+ return true
88
+ }
89
+
90
+ const excludeFileOf = (mainRoot: string, run: RunTool): string | null => {
91
+ const answer = run('git', ['-C', mainRoot, 'rev-parse', '--path-format=absolute', '--git-common-dir'])
92
+ return answer.exitCode === 0 && answer.output !== '' ? join(answer.output, 'info', 'exclude') : null
93
+ }
94
+
95
+ const excludeHerdrDirectory = (mainRoot: string, run: RunTool): string[] => {
96
+ const file = excludeFileOf(mainRoot, run)
97
+ if (file === null) return []
98
+ const merged = withExcludeLine(readOrEmpty(file), BOOTSTRAP_EXCLUDE_LINE)
99
+ if (merged === null) return []
100
+ writeIfChanged(file, merged)
101
+ return [`Added ${BOOTSTRAP_EXCLUDE_LINE} to the git exclude file.`]
102
+ }
103
+
104
+ /** Exit 1 is git's "not ignored"; any other failure says nothing about the path and is not reported. */
105
+ const notIgnored = ({ mainRoot, run }: BootstrapInput, path: string): boolean =>
106
+ run('git', ['-C', mainRoot, 'check-ignore', '-q', '--', path]).exitCode === 1
107
+
108
+ const setupScript = (mainRoot: string, install: string): string =>
109
+ existsSync(join(mainRoot, NODE_VERSION_FILE)) ? withNodeVersion(install) : install
110
+
111
+ const writeFiles = ({ mainRoot, copy, install }: BootstrapInput): string[] => {
112
+ const written: string[] = []
113
+ if (copy.length > 0 && writeIfChanged(join(mainRoot, BOOTSTRAP_COPY_LIST), copyListContent(copy)))
114
+ written.push(`Wrote ${BOOTSTRAP_COPY_LIST} from lanes.copy.`)
115
+ if (
116
+ install !== undefined &&
117
+ writeIfChanged(join(mainRoot, BOOTSTRAP_SETUP), setupContent(setupScript(mainRoot, install)))
118
+ )
119
+ written.push(`Wrote ${BOOTSTRAP_SETUP} from commands.install.`)
120
+ return written
121
+ }
122
+
123
+ /**
124
+ * Prepares the main checkout for the herdr plugin `zerodice0.worktree-bootstrap`: its copy list and setup command from
125
+ * the config, and the `.herdr/` directory in the git exclude file. Does nothing without herdr or with the plugin
126
+ * missing or disabled. A file that is already as it should be is not rewritten. Returns the lines to print.
127
+ */
128
+ export const bootstrapHerdrWorktrees = (input: BootstrapInput): string[] => {
129
+ const listing = input.run(input.herdr, ['plugin', 'list', '--json'])
130
+ if (listing.missing || listing.exitCode !== 0 || !isBootstrapPluginEnabled(listing.output)) return []
131
+ const warning = notIgnoredWarning(input.copy.filter((path) => notIgnored(input, path)))
132
+ return [...writeFiles(input), ...excludeHerdrDirectory(input.mainRoot, input.run), ...(warning ? [warning] : [])]
133
+ }
134
+
135
+ /** `bootstrapHerdrWorktrees` for the project at `projectRoot` (also a lane's worktree), with the real herdr and git. */
136
+ export const bootstrapHerdrWorktreesFrom = (projectRoot: string): string[] => {
137
+ const mainRoot = mainCheckoutOrNull(projectRoot) ?? projectRoot
138
+ try {
139
+ const { config } = readResolvedConfig([...new Set([projectRoot, mainRoot])])
140
+ return bootstrapHerdrWorktrees({
141
+ mainRoot,
142
+ copy: config.lanes.copy,
143
+ install: config.commands.install,
144
+ herdr: herdrBinary(process.env),
145
+ run: spawnTool(process.env),
146
+ })
147
+ } catch (err) {
148
+ return [`Could not prepare the herdr worktree plugin: ${err instanceof Error ? err.message : String(err)}`]
149
+ }
150
+ }