jackherd 1.19.0 → 1.21.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 +8 -0
- package/README.md +18 -18
- package/dist/config.d.ts +1 -0
- package/dist/config.js +3 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +5 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +3 -3
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +5 -0
- package/src/check/skill-budget-defaults.ts +1 -1
- package/src/config/schema.ts +2 -0
- package/src/lane/command.ts +3 -1
- package/src/lane/port-command.ts +37 -0
- package/src/lane/port.ts +56 -0
- package/src/plans/settings.ts +4 -0
- package/src/sync/command-context.ts +4 -0
- package/src/sync/command.ts +2 -1
- package/src/sync/init-command.ts +5 -1
- package/src/sync/worktree-bootstrap.ts +150 -0
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.21.0
|
|
6
|
+
|
|
7
|
+
- **`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.
|
|
8
|
+
|
|
9
|
+
## 1.20.0
|
|
10
|
+
|
|
11
|
+
- **`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.
|
|
12
|
+
|
|
5
13
|
## 1.19.0
|
|
6
14
|
|
|
7
15
|
- **The coordinator clears its context at quiet moments.** The watcher prints `coordinator clear <n>k` once your coordinator's context reaches `plans.context.coordinator.clearTokens` (250 000 by default), or `clearAfterMergeTokens` (150 000) after a merge with the board written afterwards, and only when no dialog is open, nothing unread waits, no lane is blocked or has a question not yet shown, no approval waits and no PR with self-merge is open. Without your own `clearTokens` a lower `warnTokens` moves it just below that; one you set at or above `warnTokens` is refused. `coordinator warn` now points to clearing, and the hand-over with a note stays for `coordinator alert`. Run `jh sync` to get the skill texts.
|
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.
|
|
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.21.0/assets/branding/jackherd-logo-outline.svg" width="340">
|
|
3
3
|
<br>
|
|
4
4
|
</p>
|
|
5
5
|
|
|
6
|
-
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [](https://unpkg.com/jackherd/INSTALL.md) [](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.
|
|
6
|
+
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [](https://unpkg.com/jackherd/INSTALL.md) [](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.21.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.
|
|
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.21.0/assets/icons/space.svg">
|
|
19
19
|
|
|
20
20
|
<p>
|
|
21
|
-
<img src="https://unpkg.com/jackherd@1.
|
|
21
|
+
<img src="https://unpkg.com/jackherd@1.21.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.
|
|
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.21.0/assets/icons/space.svg">
|
|
24
24
|
</p>
|
|
25
25
|
|
|
26
|
-
<p><img src="https://unpkg.com/jackherd@1.
|
|
26
|
+
<p><img src="https://unpkg.com/jackherd@1.21.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.
|
|
28
|
+
<p><img src="https://unpkg.com/jackherd@1.21.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.
|
|
30
|
+
<p><img src="https://unpkg.com/jackherd@1.21.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.
|
|
32
|
+
<p><img src="https://unpkg.com/jackherd@1.21.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.
|
|
34
|
+
<p><img src="https://unpkg.com/jackherd@1.21.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.
|
|
36
|
+
<p><img src="https://unpkg.com/jackherd@1.21.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.
|
|
38
|
+
<p><img src="https://unpkg.com/jackherd@1.21.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.
|
|
40
|
+
<p><img src="https://unpkg.com/jackherd@1.21.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.21.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.
|
|
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.21.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.
|
|
77
|
+
<img src="https://unpkg.com/jackherd@1.21.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.
|
|
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.21.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.
|
|
86
|
+
<img src="https://unpkg.com/jackherd@1.21.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.
|
|
121
|
+
<img src="https://unpkg.com/jackherd@1.21.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
|
| --- | --- |
|
package/dist/config.d.ts
CHANGED
|
@@ -120,6 +120,7 @@ declare const jackherdConfigSchema: z.ZodObject<{
|
|
|
120
120
|
copy: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
121
121
|
lockFile: z.ZodOptional<z.ZodString>;
|
|
122
122
|
minFreeGb: z.ZodOptional<z.ZodNumber>;
|
|
123
|
+
basePort: z.ZodOptional<z.ZodNumber>;
|
|
123
124
|
}, z.core.$strict>>;
|
|
124
125
|
models: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
|
|
125
126
|
review: "review";
|
package/dist/config.js
CHANGED
|
@@ -706,7 +706,9 @@ var jackherdConfigSchema = z2.strictObject({
|
|
|
706
706
|
/** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand; detected from the repository when left out. */
|
|
707
707
|
lockFile: lockFilePath.optional(),
|
|
708
708
|
/** Free gigabytes the disk must hold before `jh lane space` lets a new lane install its dependencies; 5 when left out. */
|
|
709
|
-
minFreeGb: z2.number().positive().optional()
|
|
709
|
+
minFreeGb: z2.number().positive().optional(),
|
|
710
|
+
/** The port `jh lane port` starts counting from when it picks a lane's dev-server port; 3100 when left out. */
|
|
711
|
+
basePort: z2.number().int().min(1).max(65535).optional()
|
|
710
712
|
}).prefault({}),
|
|
711
713
|
models: z2.record(z2.enum(Object.values(MODEL_ROLE)), modelId).default(DEFAULT_MODELS),
|
|
712
714
|
terminal: z2.strictObject({ adapter: z2.enum(Object.values(TERMINAL_ADAPTER)).default(TERMINAL_ADAPTER.none) }).prefault({}),
|
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.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.21.0}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -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
|
|
@@ -32,7 +32,7 @@ column and the plan's dependency table are met and step 1 finds nothing.
|
|
|
32
32
|
4. **Dependencies**, installed in the worktree's pane: the same file.
|
|
33
33
|
5. **Agent with the plan's model:**
|
|
34
34
|
`herdr agent start <lane> --kind claude --pane <pane> --timeout 120000 -- --model <model-id>`.
|
|
35
|
-
- **The lane's row comes first** in the board's `## {{kit.heading.lanes}}` table: lane, port, plan,
|
|
35
|
+
- **The lane's row comes first** in the board's `## {{kit.heading.lanes}}` table: lane, port (from `jh lane port`), plan,
|
|
36
36
|
phases, starts after, model. The phases cell starts with the phase id (`P10c …`), also for a sub-phase, and
|
|
37
37
|
the plan column links the plan once it exists, in the row of every lane that implements one of its
|
|
38
38
|
phases. Without the `lane-start-guard` hook, `grep -F "| \`<lane>\`" <board>` must print the row.
|
|
@@ -63,5 +63,5 @@ herdr pane run <new-pane> "PORT=<port> {{kit.command.dev}}"
|
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
The new pane starts in the lane's worktree; check with `/proc/<pid>/cwd` (macOS: `lsof -p <pid>`) that the server runs
|
|
66
|
-
there, and before stopping one by PID. Stop only a server you started;
|
|
67
|
-
|
|
66
|
+
there, and before stopping one by PID. Stop only a server you started; another's port
|
|
67
|
+
goes to the user.
|
package/package.json
CHANGED
|
@@ -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':
|
|
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 %.
|
package/src/config/schema.ts
CHANGED
|
@@ -603,6 +603,8 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
603
603
|
lockFile: lockFilePath.optional(),
|
|
604
604
|
/** Free gigabytes the disk must hold before `jh lane space` lets a new lane install its dependencies; 5 when left out. */
|
|
605
605
|
minFreeGb: z.number().positive().optional(),
|
|
606
|
+
/** The port `jh lane port` starts counting from when it picks a lane's dev-server port; 3100 when left out. */
|
|
607
|
+
basePort: z.number().int().min(1).max(65535).optional(),
|
|
606
608
|
})
|
|
607
609
|
.prefault({}),
|
|
608
610
|
models: z.record(z.enum(Object.values(MODEL_ROLE)), modelId).default(DEFAULT_MODELS),
|
package/src/lane/command.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { startLaneHintFor } from './start-lane-hint'
|
|
|
4
4
|
|
|
5
5
|
const CLOSE = 'close'
|
|
6
6
|
const SPACE = 'space'
|
|
7
|
+
const PORT = 'port'
|
|
7
8
|
const SEND = 'send'
|
|
8
9
|
const BASE = 'base'
|
|
9
10
|
|
|
@@ -15,12 +16,13 @@ const withStartLaneHint = (code: number, args: readonly string[], cwd: string):
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
* `jh lane close …` closes a lane, `jh lane space …` checks the disk for a new one, `jh lane send …` sends its prompt
|
|
19
|
+
* `jh lane close …` closes a lane, `jh lane space …` checks the disk for a new one, `jh lane port …` names its dev-server port, `jh lane send …` sends its prompt
|
|
19
20
|
* file, `jh lane base …` names the ref its worktree starts from, everything else is `jh lane prompt …`. `close` must
|
|
20
21
|
* come first; `jh lane --board b.md close x` reaches the prompt parser and is refused there.
|
|
21
22
|
*/
|
|
22
23
|
export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
|
|
23
24
|
if (args[0] === SPACE) return (await import('./space-command')).run(args.slice(1), cwd)
|
|
25
|
+
if (args[0] === PORT) return (await import('./port-command')).run(args.slice(1), cwd)
|
|
24
26
|
if (args[0] === SEND) return (await import('./send-command')).run(args.slice(1), cwd)
|
|
25
27
|
if (args[0] === BASE)
|
|
26
28
|
return withStartLaneHint(await (await import('./base-command')).run(args.slice(1), cwd), args.slice(1), cwd)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { parseSprintLanes } from '../plan-format'
|
|
4
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
|
+
import { boardFor } from '../plans/board-pick'
|
|
6
|
+
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
7
|
+
import { isPortListening } from '../plans/port-listening'
|
|
8
|
+
import { readSettings } from '../plans/settings'
|
|
9
|
+
import { asksForHelp } from '../preview/options'
|
|
10
|
+
import { freePortNotListening, LANE_PORT_USAGE, MAX_PORT, parsePortOptions, portsOfRunningLanes } from './port'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `jh lane port [--board <board.md>]`: prints the dev-server port for a new lane, the lowest from `lanes.basePort` that
|
|
14
|
+
* nothing listens on and no running lane of the board holds. Exit 0 printed, 1 on a wrong line, an unreadable board or
|
|
15
|
+
* no free port.
|
|
16
|
+
*/
|
|
17
|
+
export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
|
|
18
|
+
if (asksForHelp(args)) {
|
|
19
|
+
console.log(LANE_PORT_USAGE)
|
|
20
|
+
return 0
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const { boardPath } = parsePortOptions(args)
|
|
24
|
+
const root = mainCheckout(cwd)
|
|
25
|
+
const settings = readSettings([root, worktreeRoot(cwd)])
|
|
26
|
+
const board = boardFor(boardPath === null ? null : resolve(cwd, boardPath), [root], settings, () => [])
|
|
27
|
+
if (board.notice) console.error(terminalSafe(board.notice))
|
|
28
|
+
const reserved = portsOfRunningLanes(parseSprintLanes(readFileSync(board.file, 'utf8'), settings.planLanguage))
|
|
29
|
+
const port = await freePortNotListening(settings.basePort, reserved, MAX_PORT, isPortListening)
|
|
30
|
+
if (port === null) throw new Error(`no free port from ${settings.basePort} to ${MAX_PORT}`)
|
|
31
|
+
console.log(port)
|
|
32
|
+
return 0
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error(terminalSafe(error instanceof Error ? error.message : String(error)))
|
|
35
|
+
return 1
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/lane/port.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SprintLane } from '../plan-format'
|
|
2
|
+
import { CLI_NAME } from '../version/release'
|
|
3
|
+
|
|
4
|
+
/** The port the first lane's dev server gets when the config names none (`lanes.basePort`). */
|
|
5
|
+
export const DEFAULT_BASE_PORT = 3100
|
|
6
|
+
|
|
7
|
+
/** The highest port a machine has. */
|
|
8
|
+
export const MAX_PORT = 65535
|
|
9
|
+
|
|
10
|
+
/** `boardPath` is null where `--board` was left out: the board is then picked as for `jh herd`. */
|
|
11
|
+
export type PortOptions = { boardPath: string | null }
|
|
12
|
+
|
|
13
|
+
const BOARD = '--board'
|
|
14
|
+
|
|
15
|
+
export const LANE_PORT_USAGE = `usage: ${CLI_NAME} lane port [${BOARD} <board.md>]
|
|
16
|
+
|
|
17
|
+
Prints the dev-server port for a new lane: the lowest port from lanes.basePort of the config (default ${DEFAULT_BASE_PORT})
|
|
18
|
+
that nothing listens on and no lane still running holds in the port column of the board. Rows of cleaned-up lanes
|
|
19
|
+
reserve nothing. Exit 1 and one line when no port up to ${MAX_PORT} is free.`
|
|
20
|
+
|
|
21
|
+
/** The lowest port from `base` up to `max` (both included) that is not in `taken`; null when there is none. */
|
|
22
|
+
export const firstFreePort = (base: number, taken: ReadonlySet<number>, max: number): number | null => {
|
|
23
|
+
for (let port = base; port <= max; port += 1) if (!taken.has(port)) return port
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The lowest port from `base` that is neither in `reserved` nor accepts a connection according to `isListening`; the
|
|
29
|
+
* probe is asked only for ports the board does not already hold. Null when none is free up to `max`.
|
|
30
|
+
*/
|
|
31
|
+
export const freePortNotListening = async (
|
|
32
|
+
base: number,
|
|
33
|
+
reserved: ReadonlySet<number>,
|
|
34
|
+
max: number,
|
|
35
|
+
isListening: (port: number) => Promise<boolean>,
|
|
36
|
+
): Promise<number | null> => {
|
|
37
|
+
const taken = new Set(reserved)
|
|
38
|
+
for (let port = firstFreePort(base, taken, max); port !== null; port = firstFreePort(port, taken, max)) {
|
|
39
|
+
if (!(await isListening(port))) return port
|
|
40
|
+
taken.add(port)
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The ports the board's lanes that are not cleaned up hold; a lane without a port in its row reserves none. */
|
|
46
|
+
export const portsOfRunningLanes = (lanes: readonly SprintLane[]): Set<number> =>
|
|
47
|
+
new Set(lanes.flatMap(({ port, closed }) => (closed || port === null ? [] : [port])))
|
|
48
|
+
|
|
49
|
+
/** Reads `[--board <file>]`; throws with the usage on anything else. */
|
|
50
|
+
export const parsePortOptions = (args: readonly string[]): PortOptions => {
|
|
51
|
+
if (args.length === 0) return { boardPath: null }
|
|
52
|
+
const [option, value, ...rest] = args
|
|
53
|
+
if (option !== BOARD || value === undefined || value.startsWith('--') || rest.length > 0)
|
|
54
|
+
throw new Error(LANE_PORT_USAGE)
|
|
55
|
+
return { boardPath: value }
|
|
56
|
+
}
|
package/src/plans/settings.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type JackherdConfig, parseJackherdConfig } from '../config'
|
|
|
4
4
|
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
5
5
|
import { readConfig } from '../config/read-config'
|
|
6
6
|
import type { ToneName } from '../config/tones'
|
|
7
|
+
import { DEFAULT_BASE_PORT } from '../lane/port'
|
|
7
8
|
import { DEFAULT_MIN_FREE_GB } from '../lane/space'
|
|
8
9
|
import type { TicketSource } from '../watch/plan-badges'
|
|
9
10
|
import type { SourceShow } from '../watch/sources'
|
|
@@ -18,6 +19,8 @@ export type PlansSettings = Pick<
|
|
|
18
19
|
adapter: JackherdConfig['terminal']['adapter']
|
|
19
20
|
/** Free gigabytes a new lane needs on the disk (`lanes.minFreeGb`). */
|
|
20
21
|
minFreeGb: number
|
|
22
|
+
/** The port `jh lane port` starts from (`lanes.basePort`). */
|
|
23
|
+
basePort: number
|
|
21
24
|
fetchIntervalMinutes: number
|
|
22
25
|
/** Whether the running task of a folded lane shimmers; `NO_COLOR` keeps it still whatever this says. */
|
|
23
26
|
shimmer: boolean
|
|
@@ -62,6 +65,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
62
65
|
skillPrefix: config.skillPrefix,
|
|
63
66
|
adapter: config.terminal.adapter,
|
|
64
67
|
minFreeGb: config.lanes.minFreeGb ?? DEFAULT_MIN_FREE_GB,
|
|
68
|
+
basePort: config.lanes.basePort ?? DEFAULT_BASE_PORT,
|
|
65
69
|
fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
|
|
66
70
|
shimmer: config.plans.shimmer,
|
|
67
71
|
linkKinds: config.plans.linkKinds,
|
|
@@ -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)),
|
package/src/sync/command.ts
CHANGED
|
@@ -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
|
-
|
|
114
|
+
const bootstrap = dryRun ? [] : context.bootstrapWorktrees(context.projectRoot)
|
|
115
|
+
return { notes: [...dryRunNote, ...foreign, ...bootstrap], result }
|
|
115
116
|
},
|
|
116
117
|
dryRun,
|
|
117
118
|
)
|
package/src/sync/init-command.ts
CHANGED
|
@@ -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: [
|
|
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
|
+
}
|