jackherd 1.26.0 → 1.26.2
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 +11 -0
- package/README.md +18 -18
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/orchestrate/references/lane-session.md +2 -0
- package/package.json +1 -1
- package/src/brief/lane-facts.ts +30 -18
- package/src/check/skill-budget-defaults.ts +3 -2
- package/src/context/command.ts +3 -1
- package/src/lane/close-command.ts +5 -3
- package/src/lane/close.ts +1 -1
- package/src/lane/send-options.ts +3 -2
- package/src/lane/send.ts +8 -1
- package/src/lane/subagent-view.ts +21 -0
- package/src/lane-prompt/command.ts +4 -1
- package/src/plans/active-tasks.ts +4 -2
- package/src/plans/activity-read.ts +3 -2
- package/src/plans/command.ts +3 -1
- package/src/plans/coordinator-row.ts +7 -2
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/lane-discovery.ts +87 -0
- package/src/plans/lane-worktrees.ts +56 -0
- package/src/plans/sent-files.ts +10 -5
- package/src/plans/settings.ts +9 -1
- package/src/plans/snapshot.ts +23 -8
- package/src/plans/subagents-read.ts +17 -2
- package/src/plans/subagents.ts +3 -2
- package/src/plans/unlisted-lanes.ts +13 -0
- package/src/resume/command.ts +1 -1
- package/src/resume/coordinator-read.ts +23 -13
- package/src/resume/decision.ts +3 -0
- package/src/resume/record.ts +24 -5
- package/src/sync/command.ts +16 -2
- package/src/sync/downgrade.ts +9 -0
- package/src/sync/if-changed.ts +6 -7
- package/src/update/command.ts +17 -0
- package/src/update/registry-wait.ts +44 -0
- package/src/update/update.ts +23 -0
- package/src/version/registry.ts +8 -0
- package/src/watch/command.ts +46 -13
- package/src/watch/lane-watch.ts +13 -0
- package/src/watch/obsolete-proposals.ts +8 -6
- package/src/watch/obsolete-records.ts +3 -1
- package/src/watch/running-subagents.ts +3 -2
- package/src/watch/task-signal-read.ts +4 -1
- package/src/watch/unlinked-artifacts-read.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
What changed for people who use jackherd, newest first.
|
|
4
4
|
|
|
5
|
+
## 1.26.2
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **`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.
|
|
9
|
+
- **`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.
|
|
10
|
+
- **`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.
|
|
11
|
+
|
|
12
|
+
## 1.26.1
|
|
13
|
+
|
|
14
|
+
- **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.
|
|
15
|
+
|
|
5
16
|
## 1.26.0
|
|
6
17
|
|
|
7
18
|
- **"Your turn" clears what is obsolete, and says why.** A closed entry now keeps the reason it was closed (`jh waiting close <id> --reason "<text>"`, `jh waiting --closed` lists the last 24 hours). The watcher closes an older sent file by itself when the same file was sent again (`newer version: <id>`, whoever sent it) or its lane's worktree is gone (`lane removed: <name>`), and reports it as `waiting closed`. What only looks obsolete is proposed once as `waiting obsolete?`: a send whose lane's pull request was merged or closed, a board row naming such a pull request or a removed lane, a question a later dialog answered in nearly the same words (`superseded`) and a question whose session ended (`session ended`). Nothing but a send is ever closed without you. A refusal by another hook is no longer recorded as an open question. New hook `question-session-end` (on `SessionEnd`) marks the ending session's open questions. The orchestrate skill's `waiting.md` and `events.md` say how to work through a proposal. Run `jh sync` to get the hook and the 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.26.
|
|
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.2/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.26.
|
|
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.26.2/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.
|
|
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.2/assets/icons/space.svg">
|
|
19
19
|
|
|
20
20
|
<p>
|
|
21
|
-
<img src="https://unpkg.com/jackherd@1.26.
|
|
21
|
+
<img src="https://unpkg.com/jackherd@1.26.2/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.
|
|
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.2/assets/icons/space.svg">
|
|
24
24
|
</p>
|
|
25
25
|
|
|
26
|
-
<p><img src="https://unpkg.com/jackherd@1.26.
|
|
26
|
+
<p><img src="https://unpkg.com/jackherd@1.26.2/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.
|
|
28
|
+
<p><img src="https://unpkg.com/jackherd@1.26.2/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.
|
|
30
|
+
<p><img src="https://unpkg.com/jackherd@1.26.2/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.
|
|
32
|
+
<p><img src="https://unpkg.com/jackherd@1.26.2/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.
|
|
34
|
+
<p><img src="https://unpkg.com/jackherd@1.26.2/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.
|
|
36
|
+
<p><img src="https://unpkg.com/jackherd@1.26.2/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.
|
|
38
|
+
<p><img src="https://unpkg.com/jackherd@1.26.2/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.
|
|
40
|
+
<p><img src="https://unpkg.com/jackherd@1.26.2/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.2/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.
|
|
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.2/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.
|
|
77
|
+
<img src="https://unpkg.com/jackherd@1.26.2/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.
|
|
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.2/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.
|
|
86
|
+
<img src="https://unpkg.com/jackherd@1.26.2/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.
|
|
121
|
+
<img src="https://unpkg.com/jackherd@1.26.2/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/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.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.26.2}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -24,3 +24,5 @@ answer on, so the watcher reports nothing in between.
|
|
|
24
24
|
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
|
+
|
|
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
package/src/brief/lane-facts.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
18
|
-
const candidates = [join(
|
|
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) &&
|
|
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) =>
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
}
|
|
@@ -55,8 +55,9 @@ export const KIT_SKILL_TARGETS: Readonly<Record<string, number>> = {
|
|
|
55
55
|
// Core cut: the request on `lane alert` moved here (+56); ending a lane names `herdr agent prompt <lane> /exit`,
|
|
56
56
|
// the form the merge guard passes (+23).
|
|
57
57
|
'.claude/skills/orchestrate/references/lane-handover.md': 360,
|
|
58
|
-
// Ending the agent names `herdr agent prompt <lane> /exit`, the form the merge guard passes (+25).
|
|
59
|
-
|
|
58
|
+
// Ending the agent names `herdr agent prompt <lane> /exit`, the form the merge guard passes (+25). Taking over a
|
|
59
|
+
// worktree: the lane's name must equal its directory's, the watcher's `lane <name> not watched` points here (+15).
|
|
60
|
+
'.claude/skills/orchestrate/references/lane-session.md': 300,
|
|
60
61
|
'.claude/skills/orchestrate/references/lane-setup.md': 440,
|
|
61
62
|
// Open up: an adopted plan is committed like a missing one, and a tracked copy is restored after the merge.
|
|
62
63
|
// Core cut: the question of § 2 step 0 (the adoption's diff round, the approval it is, `<topic>`, a no) (+60).
|
package/src/context/command.ts
CHANGED
|
@@ -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
|
|
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
|
|
@@ -5,6 +5,7 @@ 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'
|
|
@@ -243,13 +244,14 @@ const contextOf = (options: CloseOptions, cwd: string): { context: CloseContext;
|
|
|
243
244
|
const settings = readSettings([root, worktreeRoot(cwd)])
|
|
244
245
|
const boardPath = options.boardPath === null ? null : resolve(cwd, options.boardPath)
|
|
245
246
|
const board = boardFor(boardPath, [root], settings, () => [options.lane])
|
|
246
|
-
const
|
|
247
|
-
|
|
247
|
+
const placed = settingsPlacingBoardLanes(root, settings, board.file, options.lane)
|
|
248
|
+
const worktree = worktreeOf(root, placed, options.lane)
|
|
249
|
+
return { context: { root, cwd, settings: placed, worktree, boardFile: board.file }, notice: board.notice }
|
|
248
250
|
}
|
|
249
251
|
|
|
250
252
|
/**
|
|
251
253
|
* `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
|
|
254
|
+
* `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
255
|
* dry run, 1 refused or failed.
|
|
254
256
|
*/
|
|
255
257
|
export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
|
package/src/lane/close.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { LANE_CLOSE_COMMAND } from './close-command-line'
|
|
|
9
9
|
/** The facts the check reads, and the herdr workspace the worktree is open in, null when none is. */
|
|
10
10
|
export type CloseSurvey = { facts: CloseFacts; workspace: string | null }
|
|
11
11
|
|
|
12
|
-
/** What `jh lane close` was asked for; `worktree` comes from the
|
|
12
|
+
/** What `jh lane close` was asked for; `worktree` comes from the lane's name or from the directory its agent runs in (`worktreeOf`), never from a reply. */
|
|
13
13
|
export type CloseRequest = {
|
|
14
14
|
lane: string
|
|
15
15
|
worktree: string
|
package/src/lane/send-options.ts
CHANGED
|
@@ -7,8 +7,9 @@ const FILE = '--file'
|
|
|
7
7
|
|
|
8
8
|
export const LANE_SEND_USAGE = `usage: ${CLI_NAME} lane send <lane> [${FILE} <prompt-file>]
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
Refuses (exit 1, nothing sent) while a background agent's view is open on the lane's screen: the input line would
|
|
11
|
+
write to that agent. Otherwise sends the lane's prompt file (default: .claude/handover/<lane>-prompt.md of the main
|
|
12
|
+
checkout) to its agent and waits until it works. On a stall it reads the screen: a lane at work counts as sent, a prompt on the screen gets enter (exit 1 if that does not start the lane), else the
|
|
12
13
|
file is sent once more. A second stall exits 1 with one line for the coordinator.`
|
|
13
14
|
|
|
14
15
|
/** Reads `<lane> [--file <path>]`; throws with the usage on anything else. */
|
package/src/lane/send.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isSubagentViewOpen } from './subagent-view'
|
|
2
|
+
|
|
1
3
|
/** The codes of herdr's answer to `herdr agent prompt --wait` that do not prove the text is lost. */
|
|
2
4
|
export const SEND_CODE = { stalled: 'agent_prompt_stalled', timeout: 'timeout' } as const
|
|
3
5
|
|
|
@@ -30,6 +32,9 @@ export type SendOutcome = {
|
|
|
30
32
|
line: string
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
const SUBAGENT_VIEW_REFUSAL =
|
|
36
|
+
'the input line belongs to a background agent whose view is open, so the prompt would go to it and not to the lane; nothing was sent. Close the view (select main in the agent list) or send later.'
|
|
37
|
+
|
|
33
38
|
const isStall = ({ code }: HerdrAnswer): boolean => code === SEND_CODE.stalled || code === SEND_CODE.timeout
|
|
34
39
|
|
|
35
40
|
/** The start of the prompt's first line: what the input line shows while the text waits there, unsent. */
|
|
@@ -68,12 +73,14 @@ const recovered = async (
|
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
/**
|
|
71
|
-
* Sends the prompt text to the lane and makes sure it arrived.
|
|
76
|
+
* Sends the prompt text to the lane and makes sure it arrived. An open background agent's view refuses the send first:
|
|
77
|
+
* the input line would write to that agent. A stall or a timeout does not prove the text is lost:
|
|
72
78
|
* the screen decides. A lane at work is done; a prompt on the screen gets `enter`, and an `enter` the lane does not
|
|
73
79
|
* answer ends with exit 1, since another send would append to the waiting text; a screen without the prompt sends the
|
|
74
80
|
* text once more. A second stall, or any other error, ends with exit 1 and a line for Jack.
|
|
75
81
|
*/
|
|
76
82
|
export const sendLanePrompt = async (ports: SendPorts, lane: string, prompt: string): Promise<SendOutcome> => {
|
|
83
|
+
if (isSubagentViewOpen(await ports.screen(lane))) return outcome('failed', lane, SUBAGENT_VIEW_REFUSAL)
|
|
77
84
|
const first = await ports.prompt(lane, prompt)
|
|
78
85
|
if (first.ok) return outcome('sent', lane, 'prompt sent.')
|
|
79
86
|
if (!isStall(first)) return refused(lane, first)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A row of Claude Code's agent list that shows the main agent not selected: `◯ main`, the row of the main agent
|
|
3
|
+
* without the filled `●`. The list sits under the input line; `●` marks the agent whose view is open, so `◯ main`
|
|
4
|
+
* means a background agent's view is open and the input line writes to that agent. Seen in a real Claude Code pane:
|
|
5
|
+
* the list reads `◯ main` / `● general-purpose …`. The optional `❯` is the list cursor, which stays on the row while
|
|
6
|
+
* the list is in selection mode.
|
|
7
|
+
*/
|
|
8
|
+
const MAIN_NOT_SELECTED_ROW = /^\s*(?:❯\s+)?◯ main\s*$/m
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The empty input line of an open agent view: `❯ Message @general-purpose…`. It does not depend on how long the agent
|
|
12
|
+
* list is, which can push the `◯ main` row out of a short screen read; it shows only while the line is empty.
|
|
13
|
+
*/
|
|
14
|
+
const AGENT_INPUT_PLACEHOLDER = /^\s*❯ Message @\S/m
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether the screen text shows a background agent's view as the open one, so a prompt would go to that agent. A
|
|
18
|
+
* screen cut so short that neither mark is on it reads as no view: the send then goes ahead as it did before.
|
|
19
|
+
*/
|
|
20
|
+
export const isSubagentViewOpen = (screen: string): boolean =>
|
|
21
|
+
MAIN_NOT_SELECTED_ROW.test(screen) || AGENT_INPUT_PLACEHOLDER.test(screen)
|
|
@@ -3,6 +3,7 @@ import { dirname, join, relative, resolve } from 'node:path'
|
|
|
3
3
|
import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
|
|
4
4
|
import { boardFor } from '../plans/board-pick'
|
|
5
5
|
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
6
|
+
import { settingsPlacingBoardLanes } from '../plans/lane-worktrees'
|
|
6
7
|
import { boardRepoPathOf, readSettings, worktreeOf } from '../plans/settings'
|
|
7
8
|
import { asksForHelp } from '../preview/options'
|
|
8
9
|
import { withAdditions } from './additions'
|
|
@@ -54,7 +55,9 @@ const promptSourcesOf = (options: LanePromptOptions, cwd: string): PromptSources
|
|
|
54
55
|
},
|
|
55
56
|
board: { text: readFileSync(board.file, 'utf8'), directory, path: relative(cwd, board.file) },
|
|
56
57
|
readPlan: readFirstIn([root, checkout]),
|
|
57
|
-
readLanePlan: readFirstIn([
|
|
58
|
+
readLanePlan: readFirstIn([
|
|
59
|
+
worktreeOf(root, settingsPlacingBoardLanes(root, settings, board.file, options.lane), options.lane),
|
|
60
|
+
]),
|
|
58
61
|
root,
|
|
59
62
|
}
|
|
60
63
|
}
|
|
@@ -3,6 +3,7 @@ import { terminalSafe } from '../plan-format/terminal-text'
|
|
|
3
3
|
import { matchSignals, type OpenPlanTask } from '../task-signals/matcher'
|
|
4
4
|
import type { TaskSignal } from '../task-signals/types'
|
|
5
5
|
import type { TaskPhase } from './current-task'
|
|
6
|
+
import { type LaneWorktrees, laneOfWorktree } from './unlisted-lanes'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* A task an agent is working on: its index among the phase's checkboxes, who works on it (null while only one agent
|
|
@@ -83,8 +84,9 @@ export const freshSignals = (signals: readonly TaskSignal[], lastTickAt: number
|
|
|
83
84
|
signals.filter(({ source, startedAt }) => source !== 'jh' || lastTickAt === null || startedAt >= lastTickAt)
|
|
84
85
|
|
|
85
86
|
/** The lane a directory belongs to: the first directory below `worktrees` it lies in; null anywhere else. */
|
|
86
|
-
export const laneOfDirectory = (directory: string, worktrees: string): string | null => {
|
|
87
|
+
export const laneOfDirectory = (directory: string, worktrees: string, placed: LaneWorktrees = {}): string | null => {
|
|
87
88
|
const prefix = worktrees.endsWith(sep) ? worktrees : `${worktrees}${sep}`
|
|
88
89
|
if (!directory.startsWith(prefix)) return null
|
|
89
|
-
|
|
90
|
+
const name = directory.slice(prefix.length).split(sep)[0]
|
|
91
|
+
return name ? laneOfWorktree(name, placed) : null
|
|
90
92
|
}
|
|
@@ -3,7 +3,7 @@ import { keptLogEntry } from '../context/session-log'
|
|
|
3
3
|
import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
|
|
4
4
|
import { isRecord } from '../shared/is-record'
|
|
5
5
|
import { type RunningSubagent, type SubagentActivity, type SubagentLog, subagentLabelOf } from './subagents'
|
|
6
|
-
import {
|
|
6
|
+
import { type LaneWorktrees, laneOfCwd } from './unlisted-lanes'
|
|
7
7
|
|
|
8
8
|
/** What a running subagent did last, with the name the herd calls it by. */
|
|
9
9
|
export type SubagentLine = { label: string; description: string | null; line: string }
|
|
@@ -83,11 +83,12 @@ export const withSubagentLines = (
|
|
|
83
83
|
running: readonly RunningSubagent[],
|
|
84
84
|
logs: readonly SubagentLog[],
|
|
85
85
|
worktreeDirectory: string,
|
|
86
|
+
placed: LaneWorktrees = {},
|
|
86
87
|
): Map<string, LaneSubagents> => {
|
|
87
88
|
const result = new Map<string, LaneSubagents>(lanes)
|
|
88
89
|
const tails = new Map(logs.flatMap((log) => [[labelOfLog(log), log.tail] as const]))
|
|
89
90
|
for (const { label, cwd, description } of running) {
|
|
90
|
-
const lane = cwd === null ? null :
|
|
91
|
+
const lane = cwd === null ? null : laneOfCwd(cwd, worktreeDirectory, placed)
|
|
91
92
|
const tail = tails.get(label)
|
|
92
93
|
const known = lane === null ? undefined : result.get(lane)
|
|
93
94
|
const line = tail === undefined ? null : activityLineOf(tail)
|
package/src/plans/command.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { fetchPullRequests } from './github'
|
|
|
10
10
|
import { claimOwnTerminal, startExclusive } from './herd-claim'
|
|
11
11
|
import { recallLanes, rememberLanes } from './herd-memory'
|
|
12
12
|
import { publishedKey } from './herd-state'
|
|
13
|
+
import { settingsPlacingBoardLanes } from './lane-worktrees'
|
|
13
14
|
import { runLive } from './live'
|
|
14
15
|
import { parseOptions } from './options'
|
|
15
16
|
import { ENSURE_EXIT } from './plan-panes'
|
|
@@ -41,6 +42,7 @@ const printOnceWithNotice = async (source: Source, notice: string | null): Promi
|
|
|
41
42
|
await printOnce(source)
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/** The settings placing each open lane of the board by where its agent runs. */
|
|
44
46
|
/**
|
|
45
47
|
* `jh herd [<board.md>] [--lane <name>] [--once | --ensure]`: a live view of a board's lanes and their plan
|
|
46
48
|
* progress; `--ensure` instead brings back the missing herd panes beside the coordinator and each lane.
|
|
@@ -58,7 +60,7 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
|
|
|
58
60
|
if (options.ensure) {
|
|
59
61
|
const { lines, exitCode } =
|
|
60
62
|
settings.adapter === TERMINAL_ADAPTER.herdr
|
|
61
|
-
? ensurePlanPaneRun(root, settings, picked.file)
|
|
63
|
+
? ensurePlanPaneRun(root, settingsPlacingBoardLanes(root, settings, picked.file, null), picked.file)
|
|
62
64
|
: { lines: ['the terminal adapter is not herdr: no panes to bring back'], exitCode: ENSURE_EXIT.done }
|
|
63
65
|
for (const line of lines.length > 0 ? lines : ['every herd pane is running']) console.log(line)
|
|
64
66
|
return exitCode
|
|
@@ -7,6 +7,7 @@ import { fit, visibleLength } from '../terminal/text'
|
|
|
7
7
|
import { AGENT_STATUS, type LaneAgent } from '../terminal/types'
|
|
8
8
|
import { laneOfDirectory } from './active-tasks'
|
|
9
9
|
import { statusWord } from './symbols'
|
|
10
|
+
import type { LaneWorktrees } from './unlisted-lanes'
|
|
10
11
|
|
|
11
12
|
export type CoordinatorTask = Pick<TaskSignal, 'text' | 'startedAt'>
|
|
12
13
|
|
|
@@ -43,9 +44,13 @@ export const coordinatorAgentOf = (agents: readonly LaneAgent[], repository: str
|
|
|
43
44
|
* The coordinator's task: the newest signal reported outside the lanes' worktrees, where the coordinator works (its
|
|
44
45
|
* Claude task list, or `jh task start` from the main checkout); null without one.
|
|
45
46
|
*/
|
|
46
|
-
export const coordinatorTaskOf = (
|
|
47
|
+
export const coordinatorTaskOf = (
|
|
48
|
+
signals: readonly TaskSignal[],
|
|
49
|
+
worktrees: string,
|
|
50
|
+
placed: LaneWorktrees = {},
|
|
51
|
+
): TaskSignal | null =>
|
|
47
52
|
signals
|
|
48
|
-
.filter(({ cwd }) => laneOfDirectory(cwd, worktrees) === null)
|
|
53
|
+
.filter(({ cwd }) => laneOfDirectory(cwd, worktrees, placed) === null)
|
|
49
54
|
.reduce<TaskSignal | null>(
|
|
50
55
|
(newest, signal) => (newest && newest.startedAt >= signal.startedAt ? newest : signal),
|
|
51
56
|
null,
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
withAgentPanes,
|
|
22
22
|
withForeground,
|
|
23
23
|
} from './plan-panes'
|
|
24
|
-
import { type PlansSettings } from './settings'
|
|
24
|
+
import { type PlansSettings, worktreeOf } from './settings'
|
|
25
25
|
|
|
26
26
|
const panesWithForeground = (agentPanes: readonly string[]): { panes: PaneState[]; notes: string[] } =>
|
|
27
27
|
withForeground(withAgentPanes(paneListOf(runHerdr(['pane', 'list'])), agentPanes), (pane) =>
|
|
@@ -32,7 +32,7 @@ const panesWithForeground = (agentPanes: readonly string[]): { panes: PaneState[
|
|
|
32
32
|
const lanesOf = (root: string, settings: PlansSettings, boardFile: string) =>
|
|
33
33
|
parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.boardLanguage)
|
|
34
34
|
.filter((lane) => !lane.closed)
|
|
35
|
-
.map((lane) => ({ name: lane.name, cwd:
|
|
35
|
+
.map((lane) => ({ name: lane.name, cwd: worktreeOf(root, settings, lane.name) }))
|
|
36
36
|
.filter((lane) => existsSync(lane.cwd))
|
|
37
37
|
|
|
38
38
|
/** Carries out one action; a failure becomes the action's line, so the other views are still tried. */
|