jackherd 1.12.5 → 1.12.7

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.12.7
6
+
7
+ - **Newest first in the herd.** The rows of Your turn that wait on your answer (the board's open questions, a session's questions, the files and Artifacts sent to you) stand newest first; what frees a lane still comes before the rest, newest first within it. A since without a year counts in the year nearest to now, so a row of 31 December stands under one of 1 January. With more than five rows, the oldest are the ones under `▸ n more`. An opened source lists its entries newest first by the time each shows: before, an entry with its own time from the source (say, when an error was last seen) could stand among entries in the order the watcher saw them change. Rows of the same time keep their order. Appointments keep their order, the ones without a time first, then the next due.
8
+
9
+ ## 1.12.6
10
+
11
+ - **The coordinator has a line in the herd.** While every lane rests and the coordinator works (a diagnosis, a review, a release), the herd no longer looks like a standstill: a line over the lanes, under "Your turn" and "Sources", set like the head of a block of one line, reads `● Jack · working · <task> · since 12:54`: the coordinator's state, the task it reported with `jh task start` from the main checkout and since when, its context fill at the right edge; at rest `○ Jack · idle`, without the last task. The name stands in the column of the block names. While the coordinator works, its task shimmers like the running task of a folded lane (and stands still in teal with `plans.shimmer: false`). A long task is cut with `…`, the time and the fill stay. The line is no lane and stays when "Lanes" is folded. That task no longer shows as a separate `now:` line. A lane's own herd shows no coordinator line. The orchestrate skill tells the coordinator to report each step of its own that way, and `jh task done` at its end.
12
+
5
13
  ## 1.12.5
6
14
 
7
15
  - **A lane that stands still is news.** Since 1.12.4 an unchanged heartbeat stays silent, so a working lane whose phase, ticks and subagents do not move would go unnoticed. `jh watch --stream` now reports such a lane once, after three heartbeat intervals without a move (45 minutes at the default 15), as its own line `lane <name> stalled <n> min`, and stays silent until the lane moves again. The orchestrate skill tells the coordinator to read that lane, instead of comparing counts across heartbeats.
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.12.5/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.12.7/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)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.5/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)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.7/assets/icons/space.svg">
7
7
 
8
8
  # Jack herds your coding agents.
9
9
 
@@ -15,25 +15,25 @@ 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.12.5/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.12.7/assets/icons/space.svg">
19
19
 
20
20
  <p>
21
- <img src="https://unpkg.com/jackherd@1.12.5/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.12.7/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.12.5/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.12.7/assets/icons/space.svg">
24
24
  </p>
25
25
 
26
- <p><img src="https://unpkg.com/jackherd@1.12.5/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.12.7/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.12.5/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A blocked lane, a red check, a review comment: Jack wakes, and the herd view shows where every lane stands.</p>
28
+ <p><img src="https://unpkg.com/jackherd@1.12.7/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A blocked lane, a red check, a review comment: Jack wakes, and the herd view shows where every lane stands.</p>
29
29
 
30
- <p><img src="https://unpkg.com/jackherd@1.12.5/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments go to the lane that fixes them, with a test. <code>/jh-follow</code> brings anything with a CLI or an API into the herd: tickets, error logs, status pages, deploys, and whatever else you point it at.</p>
30
+ <p><img src="https://unpkg.com/jackherd@1.12.7/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments go to the lane that fixes them, with a test. <code>/jh-follow</code> brings anything with a CLI or an API into the herd: tickets, error logs, status pages, deploys, and whatever else you point it at.</p>
31
31
 
32
- <p><img src="https://unpkg.com/jackherd@1.12.5/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>Picks up where you left off.</b><br>Plan and board hold the state, not the chat.</p>
32
+ <p><img src="https://unpkg.com/jackherd@1.12.7/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>Picks up where you left off.</b><br>Plan and board hold the state, not the chat.</p>
33
33
 
34
- <p><img src="https://unpkg.com/jackherd@1.12.5/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.</p>
34
+ <p><img src="https://unpkg.com/jackherd@1.12.7/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.</p>
35
35
 
36
- <p><img src="https://unpkg.com/jackherd@1.12.5/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Nothing is committed, pushed or merged without your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.5/assets/icons/space.svg"></p>
36
+ <p><img src="https://unpkg.com/jackherd@1.12.7/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Nothing is committed, pushed or merged without your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.7/assets/icons/space.svg"></p>
37
37
 
38
38
  ## Quickstart
39
39
 
@@ -69,22 +69,22 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
69
69
  /jh-orchestrate docs/plans/board-<name>/board.md
70
70
  ```
71
71
 
72
- 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. → [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.5/assets/icons/space.svg">
72
+ 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. → [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.7/assets/icons/space.svg">
73
73
 
74
74
  ## Follow anything outside GitHub
75
75
 
76
76
  Whatever you check by hand, Jack can watch: tickets, error logs, status pages, deploys. One `/jh-follow`, one yes, and every change comes to you like this:
77
77
 
78
- <img src="https://unpkg.com/jackherd@1.12.5/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
+ <img src="https://unpkg.com/jackherd@1.12.7/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.">
79
79
 
80
- The herd sums up your sources in one `Sources` line: per source its new entries, or when it last ran, red while it fails; opened, a click on an entry opens it. A ticket shows on the lane that works it, and a row in Your turn that came from a source starts with that source's label.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.5/assets/icons/space.svg">
80
+ The herd sums up your sources in one `Sources` line: per source its new entries, or when it last ran, red while it fails; opened, a click on an entry opens it. A ticket shows on the lane that works it, and a row in Your turn that came from a source starts with that source's label.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.7/assets/icons/space.svg">
81
81
 
82
82
  ## In detail
83
83
 
84
84
  <details>
85
85
  <summary><b>What Jack does on his own, and what waits for you</b></summary>
86
86
 
87
- <img src="https://unpkg.com/jackherd@1.12.5/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
+ <img src="https://unpkg.com/jackherd@1.12.7/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.">
88
88
 
89
89
  | On his own | After your approval | Never |
90
90
  | --- | --- | --- |
@@ -117,7 +117,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
117
117
 
118
118
  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.
119
119
 
120
- <img src="https://unpkg.com/jackherd@1.12.5/docs/skills-dark.svg" alt="/jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
120
+ <img src="https://unpkg.com/jackherd@1.12.7/docs/skills-dark.svg" alt="/jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
121
121
 
122
122
  | Skill | Does |
123
123
  | --- | --- |
@@ -177,8 +177,8 @@ It has five blocks, top down. **Your turn** holds everything that waits on you,
177
177
 
178
178
  How the herd reads the board:
179
179
 
180
- - **Your turn** takes its rows from the board's open questions, the questions a session left in its dialog, the files and Artifacts sent to you, the board's appointments and its next section. What blocks a lane comes first, then the oldest; five rows show, then `▸ n more · k older than 7 days`. Appointments show for the next seven days, three of them, then `▸ n later`. `unblocks …` under a row names the lanes it frees (the board's Unblocks column). The head keeps the count, how many rows block lanes and the age of the oldest, folded too. At the selected row, `[ask]` asks Jack to put the question to you, `[done]` closes the row, `[run]` asks Jack to run an appointment now and `[move]` to ask you for a new time; enter never presses `[done]`. A row goes once its board row is closed or after `jh waiting close <id>`.
181
- - **Sources** shows, per source that is no ticket, its entries and how many are new, or when it last ran: amber with a new entry that is not in Your turn yet, red while it fails. Opened, a click on an entry opens it; `↑ in your turn` marks one a row of Your turn asks about.
180
+ - **Your turn** takes its rows from the board's open questions, the questions a session left in its dialog, the files and Artifacts sent to you, the board's appointments and its next section. What blocks a lane comes first, then the newest; five rows show, then `▸ n more · k older than 7 days`. Appointments show for the next seven days, three of them, then `▸ n later`. `unblocks …` under a row names the lanes it frees (the board's Unblocks column). The head keeps the count, how many rows block lanes and the age of the oldest, folded too. At the selected row, `[ask]` asks Jack to put the question to you, `[done]` closes the row, `[run]` asks Jack to run an appointment now and `[move]` to ask you for a new time; enter never presses `[done]`. A row goes once its board row is closed or after `jh waiting close <id>`.
181
+ - **Sources** shows, per source that is no ticket, its entries and how many are new, or when it last ran: amber with a new entry that is not in Your turn yet, red while it fails. Opened, its entries stand newest first by the time they show, and a click on an entry opens it; `↑ in your turn` marks one a row of Your turn asks about.
182
182
  - **Lanes** opens at the start. A lane that works or is blocked opens by itself only when it first appears; after that, opening and folding are yours. A lane symbol says the state (`●` working, `◆` blocked, `◉` the agent finished its answer, `○` idle, `(·) no agent` a worktree nobody works in); only blocked wears its word. A resting lane names what it waits on: `waits on you` with the open questions of its report, `waits on` a row of Your turn, `after` its board row's starts-after cell, or `idle · no reason`.
183
183
  - **Waiting** lists the lanes not started yet: no worktree yet, something left to do, and what each starts after; it starts folded. Finished lanes (cleaned up, or every phase they name ticked) are not shown.
184
184
  - **An open lane** shows, top down: its branch, the model and how long it idles; its link chips on one line, `+n` for the rest; its pull requests; the phases it works on, each with its bar and its first four open tasks, the running ones first, `▸ n more open · k done` to list them all; `▸ next` with the phases that wait, as ranges; `▸ done` with the finished ones. A lane shows only the phases its board rows name; a lane without a worktree reads its plan from the main checkout. A folded lane line names the phase moved last, `+n` more active ones and its count, and a reported task below it.
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.12.5"
3
+ version = "1.12.7"
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.12.5}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.12.7}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -99,7 +99,7 @@ When the user wants a plan's lanes merged without a question for a while: § 5,
99
99
  2. **Worktree workspace** from `<base>`: `origin/{{kit.baseBranch}}` when, after
100
100
  `git fetch origin {{kit.baseBranch}}`, `git cat-file -e origin/{{kit.baseBranch}}:<plan>` succeeds
101
101
  for the lane's plan, else `origin/docs/plan-<topic>` of step 0. The file decides, not the state of
102
- the PR, so a lane started after a hand-over or after the merge gets the right base:
102
+ the PR, so a later lane gets the right base:
103
103
  ```bash
104
104
  herdr worktree create --cwd <main-checkout> --branch <branch> --base <base> \
105
105
  --path <main-checkout>/{{kit.worktreeDirectory}}/<lane> --label "<lane> :<port>" --no-focus
@@ -115,8 +115,8 @@ When the user wants a plan's lanes merged without a question for a while: § 5,
115
115
  `## {{kit.heading.lanes}}` table has the lane's row: lane, port, plan, phases, starts after,
116
116
  model. The phases cell starts with the phase id (`P10c …`), also for a sub-phase, and the plan
117
117
  column links the plan once it exists, in the PR that adds the plan, in the row of every lane that
118
- implements one of its phases. `/plan` may have written the row
119
- already. Check that this prints it, and write it first if it prints nothing:
118
+ implements one of its phases. Check that this prints it, else
119
+ write it first:
120
120
  ```bash
121
121
  grep -F "| \`<lane>\`" <board>
122
122
  ```
@@ -147,8 +147,8 @@ at it before the PR question (§ 5). The command is the project's dev server on
147
147
  (`PORT=<port> {{kit.command.dev}}`, unless the project supplement names another).
148
148
 
149
149
  Never in the lane's shell: the agent would block on it. Split a pane inside the lane's workspace
150
- and run it there, **beside the agent** (`--direction right`), so the agent's dialogs and diffs keep
151
- their height:
150
+ and run it there, **beside the agent** (`--direction right`), so the agent keeps
151
+ its height:
152
152
 
153
153
  ```bash
154
154
  herdr pane split <lane-pane> --direction right --no-focus
@@ -221,9 +221,10 @@ twice.
221
221
  `stopped by SIGTERM` from outside, or `watcher stopped: <reason>`. Start it before answering the
222
222
  user. A watcher with named lanes is restarted with every lane still running.
223
223
 
224
- **Herd for the user.** The herd shows the user where every lane stands. The
225
- watcher keeps the herd panes beside your pane and beside each lane of the board and prints a line per
226
- view it started. On `herd --ensure failed, still watching: <reason>`, fix the cause and run
224
+ **Herd for the user.** The herd shows the user where every lane and you stand: report each step of
225
+ your own with `jh task start "<task>"` from the main checkout and
226
+ `jh task done` at its end. The watcher keeps
227
+ the herd panes beside yours and each lane's and prints a line per view it started. On `herd --ensure failed, still watching: <reason>`, fix the cause and run
227
228
  `bunx jackherd herd --ensure` from your pane. How the herd picks a lane's phases is in
228
229
  [`references/herd.md`](references/herd.md). `herd --once` prints a single frame for you to read; never
229
230
  run the live view in your own shell.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.12.5",
3
+ "version": "1.12.7",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -5,8 +5,14 @@ export const DAY_MS = 86_400_000
5
5
  /** A day, month, hour or minute with a leading zero: `7` → `07`. */
6
6
  export const twoDigits = (value: number): string => String(value).padStart(2, '0')
7
7
 
8
+ /** The time of day: `HH:MM`. */
9
+ export const timeOfDayText = (at: number): string => {
10
+ const date = new Date(at)
11
+ return `${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`
12
+ }
13
+
8
14
  /** A time as the for-you rows show it: `DD.MM. HH:MM`. */
9
15
  export const clockText = (at: number): string => {
10
16
  const date = new Date(at)
11
- return `${twoDigits(date.getDate())}.${twoDigits(date.getMonth() + 1)}. ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`
17
+ return `${twoDigits(date.getDate())}.${twoDigits(date.getMonth() + 1)}. ${timeOfDayText(at)}`
12
18
  }
@@ -0,0 +1,95 @@
1
+ import type { ContextReading } from '../context'
2
+ import { COORDINATOR_NAME_PREFIX, resolveCoordinatorName } from '../context/coordinator-name'
3
+ import { clockText, timeOfDayText } from '../plan-format/clock'
4
+ import { terminalSafe } from '../plan-format/terminal-text'
5
+ import type { TaskSignal } from '../task-signals/types'
6
+ import { fit, visibleLength } from '../terminal/text'
7
+ import { AGENT_STATUS, type LaneAgent } from '../terminal/types'
8
+ import { laneOfDirectory } from './active-tasks'
9
+ import { statusWord } from './symbols'
10
+
11
+ export type CoordinatorTask = Pick<TaskSignal, 'text' | 'startedAt'>
12
+
13
+ /** What the herd's coordinator line shows: the agent's name and state, its task, and its fill when the log gives one. */
14
+ export type CoordinatorView = {
15
+ name: string
16
+ status: string | null
17
+ task: CoordinatorTask | null
18
+ context: ContextReading | null
19
+ /** When the herd read it: a task of the same day shows its time of day, an older one its date. */
20
+ readAt: number
21
+ }
22
+
23
+ /** How the coordinator's text is set: the cells it may take, the paint of its state, the style of its task. */
24
+ export type CoordinatorTextOptions = {
25
+ width?: number
26
+ paintState?: (state: string) => string
27
+ taskStyle?: (task: string) => string
28
+ }
29
+
30
+ /**
31
+ * The coordinator among the terminal's agents: `jack-<repository>` (the default name of `/orchestrate` § 0), else the
32
+ * old `orchestrator-<repository>`; null when none runs. A name given to the watcher with `--coordinator` is not known here.
33
+ */
34
+ export const coordinatorAgentOf = (agents: readonly LaneAgent[], repository: string): LaneAgent | null => {
35
+ const name = resolveCoordinatorName(
36
+ repository,
37
+ agents.map((agent) => agent.name),
38
+ )
39
+ return agents.find((agent) => agent.name === name) ?? null
40
+ }
41
+
42
+ /**
43
+ * The coordinator's task: the newest signal reported outside the lanes' worktrees, where the coordinator works (its
44
+ * Claude task list, or `jh task start` from the main checkout); null without one.
45
+ */
46
+ export const coordinatorTaskOf = (signals: readonly TaskSignal[], worktrees: string): TaskSignal | null =>
47
+ signals
48
+ .filter(({ cwd }) => laneOfDirectory(cwd, worktrees) === null)
49
+ .reduce<TaskSignal | null>(
50
+ (newest, signal) => (newest && newest.startedAt >= signal.startedAt ? newest : signal),
51
+ null,
52
+ )
53
+
54
+ const isSameDay = (first: number, second: number): boolean =>
55
+ new Date(first).toDateString() === new Date(second).toDateString()
56
+
57
+ /** `HH:MM` for a time of today, `DD.MM. HH:MM` for an earlier day. */
58
+ const sinceText = (startedAt: number, now: number): string =>
59
+ isSameDay(startedAt, now) ? timeOfDayText(startedAt) : clockText(startedAt)
60
+
61
+ const roleName = (prefix: string): string => `${prefix.charAt(0).toUpperCase()}${prefix.slice(1)}`
62
+
63
+ /**
64
+ * The role name `Jack` for `jack-shop`, `Orchestrator` for the old `orchestrator-shop`: the herd's title names the
65
+ * repository. Another name stays as it is, made terminal safe.
66
+ */
67
+ export const coordinatorShortName = (name: string): string => {
68
+ const prefix = Object.values(COORDINATOR_NAME_PREFIX).find((each) => name === each || name.startsWith(`${each}-`))
69
+ return prefix ? roleName(prefix) : terminalSafe(name)
70
+ }
71
+
72
+ const TEXT_SEPARATOR = ' · '
73
+ /** The fewest cells a cut task keeps: one character and its ellipsis. */
74
+ const MIN_TASK_WIDTH = 2
75
+
76
+ const unstyled = (text: string): string => text
77
+
78
+ /**
79
+ * `working · <task> · since 12:30` in at most `width` cells, the state in `paintState`, the task alone in
80
+ * `taskStyle` (the herd's shimmer): the task is cut with `…`, state and since when never; the task is left out where
81
+ * not even its ellipsis fits. Only a working coordinator shows its task: at rest, or without a task, the state
82
+ * stands alone (`idle`), so an old task does not read as running.
83
+ */
84
+ export const coordinatorText = (
85
+ { status, task }: Pick<CoordinatorView, 'status' | 'task'>,
86
+ now: number,
87
+ { width = Number.POSITIVE_INFINITY, paintState = unstyled, taskStyle = unstyled }: CoordinatorTextOptions = {},
88
+ ): string => {
89
+ const state = terminalSafe(statusWord(status))
90
+ if (!task || status !== AGENT_STATUS.working) return paintState(state)
91
+ const since = `since ${sinceText(task.startedAt, now)}`
92
+ const room = width - visibleLength(state) - visibleLength(since) - 2 * TEXT_SEPARATOR.length
93
+ const middle = room < MIN_TASK_WIDTH ? [] : [taskStyle(fit(terminalSafe(task.text), room))]
94
+ return [paintState(state), ...middle, since].join(TEXT_SEPARATOR)
95
+ }
@@ -82,15 +82,19 @@ export const storedRead = (read: PublishedRead, key: string, at: number): HerdRe
82
82
  }
83
83
 
84
84
  /**
85
- * The snapshot with its clocks set to `now`: the sources' ages and your turn's, which the reader draws at its own
86
- * time with every read.
85
+ * The snapshot with its clocks set to `now`: the sources' ages, your turn's and the coordinator's, which the reader
86
+ * draws at its own time with every read.
87
87
  */
88
- const clockedAt = <T extends Pick<Snapshot, 'sourceBlock' | 'yourTurn'>>(snapshot: T, now: number): T => {
89
- const { sourceBlock, yourTurn, ...rest } = snapshot
88
+ const clockedAt = <T extends Pick<Snapshot, 'sourceBlock' | 'yourTurn' | 'coordinator'>>(
89
+ snapshot: T,
90
+ now: number,
91
+ ): T => {
92
+ const { sourceBlock, yourTurn, coordinator, ...rest } = snapshot
90
93
  return {
91
94
  ...rest,
92
95
  ...(sourceBlock ? { sourceBlock: { ...sourceBlock, now } } : {}),
93
96
  ...(yourTurn ? { yourTurn: { ...yourTurn, now } } : {}),
97
+ ...(coordinator ? { coordinator: { ...coordinator, readAt: now } } : {}),
94
98
  } as T
95
99
  }
96
100
 
@@ -147,6 +151,7 @@ export const laneRead = (snapshot: Snapshot, lane: string, laneNotListed: (lane:
147
151
  titles: new Map([...snapshot.titles].filter(([titled]) => titled === pane)),
148
152
  panes: pane ? { [lane]: pane } : {},
149
153
  unplaced: [],
154
+ coordinator: undefined,
150
155
  }
151
156
  }
152
157
 
@@ -33,6 +33,7 @@ import {
33
33
  } from '../watch/plan-badges'
34
34
  import type { UnplannedTask } from './active-tasks'
35
35
  import { type AgentOf, clickHint } from './click-hint'
36
+ import { type CoordinatorView, coordinatorShortName, coordinatorText } from './coordinator-row'
36
37
  import { type Frame, indented, joinFrames, narrower, rowFrame, spaced, textFrame, unpainted } from './frame'
37
38
  import { groomedText } from './groomed'
38
39
  import {
@@ -161,6 +162,8 @@ export type OverviewView = {
161
162
  groomedDays?: number | null
162
163
  /** What agents report doing that no lane takes, one `now:` line each under the title. */
163
164
  unplaced?: UnplannedTask[]
165
+ /** The coordinator's state, task and fill, one line over the lanes, under the blocks on top. */
166
+ coordinator?: CoordinatorView
164
167
  /** True when a folded lane's running task shimmers (`shimmerAt` draws it) instead of carrying its `●`. */
165
168
  shimmer?: boolean
166
169
  }
@@ -457,6 +460,26 @@ const CONTEXT_PAINT: Record<ContextLevel, (text: string) => string> = {
457
460
  const contextValue = (context: ContextReading | null | undefined): string =>
458
461
  context ? `${CONTEXT_SYMBOL}${CONTEXT_PAINT[context.level](contextLabel(context.share).padStart(CONTEXT_WIDTH))}` : ''
459
462
 
463
+ /** The coordinator's state word: working in the tone of working lanes, at rest dim as in the head of the lanes. */
464
+ const coordinatorStatePaint = (status: string | null): ((text: string) => string) =>
465
+ status === AGENT_STATUS.working || status === AGENT_STATUS.blocked ? statusPaint(status) : paint.dim
466
+
467
+ /**
468
+ * The coordinator's line over the lanes, set like the head of a block of one line: its state's symbol where a head
469
+ * has its chevron, one cell wide, so the name starts in the column of the heads' names; then
470
+ * `Jack · working · <task> · since 12:30`, its fill at the right edge where the lanes have it. The task alone is cut
471
+ * to fit, and drawn in `taskStyle` like the running task of a folded lane: it shimmers while the herd does.
472
+ */
473
+ const coordinatorLine = (view: CoordinatorView, width: number, taskStyle: TaskStyle): string => {
474
+ const paintState = coordinatorStatePaint(view.status)
475
+ const name = paint.bold(coordinatorShortName(view.name))
476
+ const head = `${paintState(statusSymbol(view.status))} ${name}${PLACE_SEPARATOR}`
477
+ const context = contextValue(view.context)
478
+ const room = width - visibleLength(head) - (context ? visibleLength(context) + 1 : 0)
479
+ const text = coordinatorText(view, view.readAt, { width: room, paintState, taskStyle })
480
+ return spread(`${head}${text}`, context, width)
481
+ }
482
+
460
483
  /**
461
484
  * The context value with a rule before the bar that follows it on a folded lane line, from `warn` on; below, blanks
462
485
  * as wide, so nothing moves when it shows. Empty without a reading.
@@ -1344,6 +1367,7 @@ export const renderScreen = (
1344
1367
  sourceBlock,
1345
1368
  groomedDays,
1346
1369
  unplaced = [],
1370
+ coordinator,
1347
1371
  shimmer,
1348
1372
  }: OverviewView,
1349
1373
  state: Selection,
@@ -1367,7 +1391,11 @@ export const renderScreen = (
1367
1391
  textFrame(parkedBlock(noAgent, width)),
1368
1392
  ...(planned.length > 0 ? [backlogFrame(planned, state, width)] : []),
1369
1393
  ]
1370
- const plainHeader = [...above, ...(stack.lines.length > 0 ? stack.lines : [''])]
1394
+ const plainHeader = [
1395
+ ...above,
1396
+ ...(stack.lines.length > 0 ? stack.lines : ['']),
1397
+ ...(coordinator ? [coordinatorLine(coordinator, width, taskStyle), ''] : []),
1398
+ ]
1371
1399
  const plainBody = spaced(sections)
1372
1400
  const blockHits = stack.hits.flatMap(focusHitsOfBlock)
1373
1401
  const focusHits = [...blockHits, ...focusHitsOfTargets(plainBody.targets, plainHeader.length)]
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { dirname, join } from 'node:path'
2
+ import { basename, dirname, join } from 'node:path'
3
3
  import type { ContextReading } from '../context'
4
+ import { coordinatorSettings } from '../context/options'
4
5
  import { type AgentLogReader, contextOfAgent } from '../context/report'
5
6
  import { agentLog, defaultProjectsDirectory } from '../context/session-log'
6
7
  import {
@@ -25,6 +26,7 @@ import { readWatchReading } from '../watch/plan-badges-read'
25
26
  import type { SourceStatus } from '../watch/source-statuses'
26
27
  import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
27
28
  import { type BoardRowsReader, boardRowsOf } from './board-rows'
29
+ import { type CoordinatorView, coordinatorAgentOf, coordinatorTaskOf } from './coordinator-row'
28
30
  import {
29
31
  type ActivityTimes,
30
32
  type LaneMemory,
@@ -140,6 +142,8 @@ export type Snapshot = {
140
142
  tones?: PlansSettings['tones']
141
143
  /** What agents report doing that no lane takes: the coordinator's own, or a lane the board does not list. */
142
144
  unplaced?: UnplannedTask[]
145
+ /** The coordinator's state, task and fill, for its line over the lanes; absent while none runs or one lane is shown. */
146
+ coordinator?: CoordinatorView
143
147
  }
144
148
 
145
149
  type LaneSources = {
@@ -414,6 +418,25 @@ const readSignals = async (
414
418
  }
415
419
  }
416
420
 
421
+ /**
422
+ * The coordinator's line for the overview, with the signal it shows as its task: its agent's state, that task and its
423
+ * fill against the coordinator's thresholds; null while no coordinator runs.
424
+ */
425
+ const coordinatorOf = (sources: LaneSources): { view: CoordinatorView; shown: TaskSignal | null } | null => {
426
+ const agent = coordinatorAgentOf(sources.agents, basename(sources.source.root))
427
+ if (!agent) return null
428
+ const shown = coordinatorTaskOf(sources.signals, sources.worktrees)
429
+ const report = contextOfAgent(agent, sources.readLog, coordinatorSettings(sources.source.settings.context))
430
+ const view = {
431
+ name: agent.name,
432
+ status: agent.status,
433
+ task: shown && { text: shown.text, startedAt: shown.startedAt },
434
+ context: report && { share: report.share, level: report.level },
435
+ readAt: sources.now,
436
+ }
437
+ return { view, shown }
438
+ }
439
+
417
440
  /** The reports no shown lane takes, as lines for the overview: the coordinator's own and those of lanes without a plan. */
418
441
  const unplacedOf = (sources: LaneSources, lanes: readonly SprintLane[], onlyLane: boolean): UnplannedTask[] => {
419
442
  if (onlyLane) return []
@@ -721,6 +744,11 @@ export const loadSnapshot = async (
721
744
  baseCommit,
722
745
  })
723
746
  if (parsed) dropUnused(parsed.entries, parsed.used)
747
+ const coordinator = options.lane ? null : coordinatorOf(laneSet.sources)
748
+ const unplacedSources = {
749
+ ...laneSet.sources,
750
+ signals: laneSet.sources.signals.filter((signal) => signal !== coordinator?.shown),
751
+ }
724
752
  await gate.pass()
725
753
  return {
726
754
  title: unknownLaneTitle ?? sprintTitleOf(markdown, sprintDirectory),
@@ -746,7 +774,8 @@ export const loadSnapshot = async (
746
774
  yourTurn: yourTurnOfBoard(liveBoard, settings.planLanguage, waiting, now),
747
775
  ...(reading?.sources ? { sourceBlock: { sources: withSourceStyle(reading.sources, settings), now } } : {}),
748
776
  tones: settings.tones,
749
- unplaced: unplacedOf(laneSet.sources, laneSet.lanes, Boolean(options.lane)),
777
+ ...(coordinator ? { coordinator: coordinator.view } : {}),
778
+ unplaced: unplacedOf(unplacedSources, laneSet.lanes, Boolean(options.lane)),
750
779
  ...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
751
780
  }
752
781
  }
package/src/plans/view.ts CHANGED
@@ -49,6 +49,7 @@ const overviewOf = (snapshot: Snapshot, shimmer: boolean): OverviewView => {
49
49
  yourTurn: snapshot.yourTurn,
50
50
  sourceBlock: snapshot.sourceBlock,
51
51
  unplaced: snapshot.unplaced,
52
+ coordinator: snapshot.coordinator,
52
53
  shimmer,
53
54
  }
54
55
  }
@@ -2,6 +2,7 @@ import { DAY_MS, twoDigits } from '../plan-format/clock'
2
2
  import type { WaitingItem } from '../plan-format/waiting'
3
3
  import type { PlanLanguage } from '../vocabulary'
4
4
  import { parseAppointmentTime } from '../watch/appointments'
5
+ import { newestFirst } from '../watch/newest-first'
5
6
  import { type AppointmentRow, appointmentRows, TIME_OPEN } from './appointment-rows'
6
7
  import { calendarDaysBetween } from './calendar-days'
7
8
  import { plain } from './markdown-text'
@@ -30,7 +31,7 @@ export type TurnRow = {
30
31
  appointment?: AppointmentRow
31
32
  }
32
33
 
33
- /** "Your turn": the now rows, what blocks a lane first, then the oldest; the planned rows, those without a time first. */
34
+ /** "Your turn": the now rows, what blocks a lane first, then the newest; the planned rows, those without a time first. */
34
35
  export type YourTurn = { now: TurnRow[]; planned: TurnRow[] }
35
36
 
36
37
  /** "Your turn" as the herd draws it, with the clock (epoch milliseconds) its ages and the week ahead are counted from. */
@@ -140,9 +141,9 @@ const plannedFrom = (row: TurnRow | undefined, appointment: AppointmentRow): Tur
140
141
  return row ? { ...planned, unblocks: row.unblocks, ...(row.waiting ? { waiting: row.waiting } : {}) } : planned
141
142
  }
142
143
 
143
- const byBlockingThenAge = (left: TurnRow, right: TurnRow): number =>
144
- Number(right.unblocks.length > 0) - Number(left.unblocks.length > 0) ||
145
- (left.at ?? Number.POSITIVE_INFINITY) - (right.at ?? Number.POSITIVE_INFINITY)
144
+ /** What frees a lane first (the orchestrate skill's `Unblocks` column), then the newest; equal rows keep their order. */
145
+ const byBlockingThenNewest = (left: TurnRow, right: TurnRow): number =>
146
+ Number(right.unblocks.length > 0) - Number(left.unblocks.length > 0) || newestFirst(left.at, right.at)
146
147
 
147
148
  const byTime = (left: TurnRow, right: TurnRow): number =>
148
149
  (left.at ?? Number.NEGATIVE_INFINITY) - (right.at ?? Number.NEGATIVE_INFINITY)
@@ -169,7 +170,7 @@ export const yourTurnOf = ({ waiting, appointments, next, now }: TurnSources): Y
169
170
  }
170
171
  const shown = [...nowRows, ...planned]
171
172
  const nextRows = next.filter((text) => !shown.some((row) => sameTopic(topicText(row), text))).map(nextRowOf)
172
- return { now: [...nowRows].sort(byBlockingThenAge), planned: [...planned, ...nextRows].sort(byTime) }
173
+ return { now: [...nowRows].sort(byBlockingThenNewest), planned: [...planned, ...nextRows].sort(byTime) }
173
174
  }
174
175
 
175
176
  /**
@@ -0,0 +1,8 @@
1
+ /**
2
+ * A sort comparator over two times in epoch milliseconds, newest first: a time nobody can read (null) goes after every
3
+ * time, and equal times (two nulls included) give 0, so the stable `Array.prototype.sort` keeps their order.
4
+ */
5
+ export const newestFirst = (left: number | null, right: number | null): number => {
6
+ if (left === null || right === null) return Number(left === null) - Number(right === null)
7
+ return right - left
8
+ }
@@ -1,3 +1,4 @@
1
+ import { newestFirst } from './newest-first'
1
2
  import { isFresh, NEW_FOR_MS } from './plan-badges'
2
3
  import { previewUrlOf } from './previews'
3
4
  import { lineText, type SourceEntry, type SourceRecord } from './sources'
@@ -16,7 +17,7 @@ export type EntryStatus = {
16
17
  isNew: boolean
17
18
  }
18
19
 
19
- /** What the herd shows of a source: when it ran last, since when it fails, its newest report and its entries, newest change first. */
20
+ /** What the herd shows of a source: when it ran last, since when it fails, its newest report and its entries, newest first by the time each shows. */
20
21
  export type SourceStatus = {
21
22
  name: string
22
23
  ranAt: number | null
@@ -48,7 +49,9 @@ const statusOf = (name: string, record: SourceRecord | undefined, now: number):
48
49
  ranAt: record?.ranAt ?? null,
49
50
  failingSince: record?.failingSince ?? null,
50
51
  latest: latestOf(record?.latest, now),
51
- entries: (record?.entries ?? []).map((entry) => entryStatusOf(entry, now)),
52
+ entries: (record?.entries ?? [])
53
+ .map((entry) => entryStatusOf(entry, now))
54
+ .sort((left, right) => newestFirst(left.at, right.at)),
52
55
  })
53
56
 
54
57
  /**