jackherd 1.12.2 → 1.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +16 -16
  3. package/dist/plan-format.d.ts +23 -11
  4. package/dist/plan-format.js +6 -3
  5. package/herdr-plugin/herdr-plugin.toml +1 -1
  6. package/install.sh +1 -1
  7. package/package.json +1 -1
  8. package/src/context/session-log.ts +4 -6
  9. package/src/plan-format/phases.ts +22 -6
  10. package/src/plan-format/progress.ts +5 -8
  11. package/src/plans/command.ts +10 -3
  12. package/src/plans/current-task.ts +3 -7
  13. package/src/plans/focus-overlay.ts +80 -0
  14. package/src/plans/git.ts +6 -11
  15. package/src/plans/herd-state.ts +201 -0
  16. package/src/plans/lane-reason.ts +1 -1
  17. package/src/plans/lane-summary.ts +6 -2
  18. package/src/plans/layout-phase.ts +1 -3
  19. package/src/plans/layout-summary.ts +1 -1
  20. package/src/plans/layout.ts +32 -31
  21. package/src/plans/link-chips.ts +7 -16
  22. package/src/plans/live.ts +215 -267
  23. package/src/plans/markdown-text.ts +4 -0
  24. package/src/plans/page-sync.ts +39 -0
  25. package/src/plans/phase-source.ts +5 -2
  26. package/src/plans/reader.ts +217 -0
  27. package/src/plans/refresh-schedule.ts +53 -0
  28. package/src/plans/snapshot.ts +32 -31
  29. package/src/plans/status-pace.ts +7 -4
  30. package/src/plans/subagents-read.ts +28 -12
  31. package/src/plans/supervisor.ts +1 -1
  32. package/src/plans/view.ts +9 -101
  33. package/src/plans/waiting-click.ts +27 -17
  34. package/src/plans/worked-phases.ts +1 -1
  35. package/src/plans/worktree-stamp.ts +12 -9
  36. package/src/plans/your-turn-rows.ts +33 -32
  37. package/src/plans/your-turn.ts +6 -4
  38. package/src/shared/bounded-memo.ts +19 -0
  39. package/src/shared/stat-cache.ts +2 -1
  40. package/src/terminal/lifetime.ts +52 -0
  41. package/src/watch/command.ts +6 -0
  42. package/src/watch/herd-publisher.ts +84 -0
  43. package/src/watch/source-entries.ts +7 -0
  44. package/src/watch/source-statuses.ts +2 -2
  45. package/src/watch/sources.ts +5 -2
  46. package/src/plans/next-rows.ts +0 -12
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.12.3
6
+
7
+ - **One read for every herd of a board.** A running watcher (`jh watch --stream`) reads the board, its plans and the lanes once and publishes the read; each herd of that board, the lane herds included, takes it, woken by the file, instead of reading everything itself every few seconds. It writes the sprint page as well, so no herd builds it. Without a running watcher, after a read older than a minute, or with a herd that runs other code than the watcher, a herd reads for itself as before. Measured with all lanes idle: a herd that follows the watcher takes about 1 % CPU, the watcher about 4-5 %. One herd plus its watcher costs a little more than in 1.12.2 (~5.6 % against ~4.3 %); from two herds on one watcher on, the whole costs less.
8
+ - **A report shows at once.** `jh task start`, an open question or a sent file wakes the reader through the records under `.jackherd/`, without waiting for the next refresh.
9
+ - **Rows on the full width.** A point of "Your turn" and an entry under "Sources" take the whole pane and are cut only at its edge (in "Your turn" before the time, which stays flush right). `[ask]`, `[done]` and the other buttons no longer keep room beside every row: they show only at the row under the selection or the mouse, flush right at the edge, laid over the end of its text (in "Your turn" over its time too), in one colour, teal, in both blocks. A point of "Your turn" cut off at the edge opens on a click, like an entry of a source, its text under its first line.
10
+ - **"Your turn" shows only what waits on you.** The lines of the board's next section no longer stand in it as `◷ … time open`; they carried no action and repeated what waited.
11
+ - **A source's own time.** An entry under "Sources" shows the time the source gives for it (`map.updatedAt`), no longer the time the watcher read it, so entries read in one run keep their own times. Without `updatedAt`, or without a date in it, it shows the last change as before.
12
+ - **A lighter read of the board.** A session whose own log has rested for more than a day is no longer searched for running subagents, so a read no longer checks the logs of every past session (about 2,000 in a busy project).
13
+ - **Fewer git calls.** The commits a lane is ahead of the base are counted only when its worktree, its commits or the base moved, like `git status` before, not on every refresh.
14
+ - **The `plan-format` export:** a phase's `touched` and `merged` are now one field `work` (`PHASE_WORK`). `touched` and `merged` stay, derived from it and marked deprecated, until the next minor release.
15
+
5
16
  ## 1.12.2
6
17
 
7
18
  - **The merge guard no longer asks about a plain script run.** A command like `timeout 170 bun --cpu-prof-dir=$S $S/profile-refresh.ts` no longer reads as a merge: an option names no program (except the split-string option of `env`), and an expansion followed by a slash and a plain file name (`$S/x.ts`, `$DIR/gh`) names that program like a plain word. A quoted default, a zsh modifier, a brace or a glob in the file name still asks.
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.2/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.3/assets/branding/jackherd-logo-outline.svg" width="340">
3
3
  <br>
4
4
  </p>
5
5
 
6
- [![npm](https://img.shields.io/npm/v/jackherd)](https://www.npmjs.com/package/jackherd) [![license: MIT](https://img.shields.io/npm/l/jackherd)](https://www.npmjs.com/package/jackherd) [![2 dependencies](https://img.shields.io/badge/dependencies-2-brightgreen)](https://www.npmjs.com/package/jackherd?activeTab=dependencies)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.2/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.3/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.2/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.3/assets/icons/space.svg">
19
19
 
20
20
  <p>
21
- <img src="https://unpkg.com/jackherd@1.12.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.">
21
+ <img src="https://unpkg.com/jackherd@1.12.3/docs/workflow-dark.svg" width="720" alt="jackherd in herdr: a lane hits a question, Jack asks you as a choice, you pick, and the lane works on while the herd view counts its progress.">
22
22
  <br>
23
- <sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.2/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.3/assets/icons/space.svg">
24
24
  </p>
25
25
 
26
- <p><img src="https://unpkg.com/jackherd@1.12.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>
26
+ <p><img src="https://unpkg.com/jackherd@1.12.3/assets/icons/lanes.svg" width="24" height="34" align="middle" alt=""> <b>Parallel where it pays.</b><br>Phases that don't collide run side by side, each lane on its own branch and worktree. Jack keeps the merge order and has the others rebase.</p>
27
27
 
28
- <p><img src="https://unpkg.com/jackherd@1.12.2/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.3/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.2/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.3/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.2/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.3/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.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.</p>
34
+ <p><img src="https://unpkg.com/jackherd@1.12.3/assets/icons/plan.svg" width="24" height="34" align="middle" alt=""> <b>Lives in your repository.</b><br>Plans, board and every decision are Markdown, reviewed like code. <code>jh init</code> gives your team the same setup.</p>
35
35
 
36
- <p><img src="https://unpkg.com/jackherd@1.12.2/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.2/assets/icons/space.svg"></p>
36
+ <p><img src="https://unpkg.com/jackherd@1.12.3/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.3/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.2/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.3/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.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
+ <img src="https://unpkg.com/jackherd@1.12.3/docs/follow-dark.svg" width="720" alt="A source (ticket, error log or status page) has a new or changed entry; jh watch reports it to Jack; Jack puts a row into Your turn, prefixed with the source's label; its [ask] button makes Jack ask you, and then a lane works it.">
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.2/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.3/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.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
+ <img src="https://unpkg.com/jackherd@1.12.3/docs/lanes-dark.svg" alt="Jack prompts two lanes and asks you for decisions and approvals; the lanes send questions back as text; jh watch reads lane state, pull requests and checks and wakes Jack.">
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.2/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.3/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
  | --- | --- |
@@ -199,19 +199,33 @@ type MisplacedDeferral = {
199
199
  target: string;
200
200
  corrected: string;
201
201
  };
202
+ /** What the worktree did to a phase, against the base branch: its ticks differ (`touched`), and the base already carries a tick of it (`merged`): a run of it was merged. */
203
+ declare const PHASE_WORK: {
204
+ readonly touched: "touched";
205
+ readonly merged: "merged";
206
+ };
207
+ type PhaseWork = (typeof PHASE_WORK)[keyof typeof PHASE_WORK];
208
+ /** `work` with the flags it replaced, which published readers of `PlanPhase` and `PhaseProgress` may still read. */
209
+ type PhaseWorkFields = {
210
+ work: PhaseWork;
211
+ /** @deprecated Read `work`: set exactly when `work` is. Removed in the next minor release. */
212
+ touched?: true;
213
+ /** @deprecated Read `work === PHASE_WORK.merged`. Removed in the next minor release. */
214
+ merged?: true;
215
+ };
202
216
  /**
203
- * `touched` is set by the phase source when the worktree's ticks differ from the base branch's, `merged` beside it
204
- * when the base branch already carries a tick of the phase: a run of it was merged. `deferred` holds the checkboxes
205
- * moved to another phase; they are not among `items`, so they count neither as open nor as done.
217
+ * `work` is set by the phase source and says nothing for a phase the worktree left as the base has it; a merged phase
218
+ * is touched as well. `deferred` holds the checkboxes moved to another phase; they are not among `items`, so they
219
+ * count neither as open nor as done.
206
220
  */
207
221
  type PlanPhase = {
208
222
  id: string;
209
223
  title: string;
210
224
  items: ChecklistItem[];
211
225
  deferred?: DeferredItem[];
212
- touched?: true;
213
- merged?: true;
214
- };
226
+ } & Partial<PhaseWorkFields>;
227
+ /** The fields a phase's `work` sets, the deprecated `touched` and `merged` derived from it. */
228
+ declare const phaseWorkFields: (work: PhaseWork) => PhaseWorkFields;
215
229
  /** A phase heading with its 1-based line, the lower-case id and the title. */
216
230
  type PhaseHeading = {
217
231
  line: number;
@@ -455,7 +469,7 @@ declare const mergedPhasesWithOpenBoxes: (markdown: string, base: string, langua
455
469
  declare const boardRowsWithoutPhase: (markdown: string, language: PlanLanguage, previousLines: ReadonlySet<string>) => RowWithoutPhase[];
456
470
 
457
471
  /**
458
- * `title` is null when the sprint names a phase the plan does not have; `touched`, `merged` and `deferred` carry the
472
+ * `title` is null when the sprint names a phase the plan does not have; `work` and `deferred` carry the
459
473
  * plan phase's own, and the deferred checkboxes are in neither `done` nor `total`.
460
474
  */
461
475
  type PhaseProgress = {
@@ -465,9 +479,7 @@ type PhaseProgress = {
465
479
  total: number;
466
480
  items: ChecklistItem[];
467
481
  deferred?: DeferredItem[];
468
- touched?: true;
469
- merged?: true;
470
- };
482
+ } & Partial<PhaseWorkFields>;
471
483
  type StepProgress = {
472
484
  step: PlanStep;
473
485
  phases: PhaseProgress[];
@@ -579,4 +591,4 @@ declare const readSprintDocument: (markdown: string, language: PlanLanguage) =>
579
591
  */
580
592
  declare const readPhaseDependencies: (planMarkdown: string, language: PlanLanguage) => Record<string, string>;
581
593
 
582
- export { ADDRESS_FORM, APPOINTMENT_VOCABULARY, APPROVAL_TEXTS, type ChecklistItem, type ColumnModel, type DeferredItem, type DriftFinding, LANE_PROMPT_OPENING, LANGUAGE_NAME, type LaneProgress, type LongPhaseTitle, type MergedPhaseWithOpenBoxes, type MisplacedDeferral, NEXT_VOCABULARY, PHASE_CELL_LIMIT, PHASE_ID_PREFIX, PHASE_TITLE_LIMIT, PLAN_LANGUAGE, PLAN_STATUS, PLAN_VOCABULARY, PROSE_REASON, type PhaseDependency, type PhaseHeading, type PhaseProgress, type PhaseSchedule, type PhaseTally, type PhaseWithoutGate, type PhaseWithoutRow, type PlanHeader, type PlanLanguage, type PlanPhase, type PlanStatus, type PlanStep, type PlanVocabulary, type PlansByHref, type ProseReason, RESPONSE_ADDRESS, type ResponseAddress, type RowWithProse, type RowWithoutPhase, SELF_MERGE_LABEL, SHORT_NAME_LIMIT, SHORT_NAME_PROBLEM, STANDING_APPROVAL_TEXTS, type ShortNameFinding, type ShortNameProblem, type SprintDocument, type SprintLane, type SprintPlanRow, type SprintPlanTable, type StatusWord, type StepProgress, WAITING_VOCABULARY, activeItem, boardRowsWithProse, boardRowsWithoutPhase, classifyStatus, columnModels, countDecisions, criticalPath, currentPhase, currentPhaseOf, deferralTarget, differsFromColumn, endsPhase, findDrift, hasLanesTable, isCheckboxLine, isDocumentationGateItem, isOpenCheckboxLine, isPhaseFinished, isPhaseOpen, isPlanLanguage, isReviewGateLine, isTickedUnmerged, laneNameOf, laneProgress, longPhaseTitles, mergedPhasesWithOpenBoxes, misplacedDeferrals, newPhasesWithoutReviewGate, onlyDocumentationOpen, parallelGroups, parallelGroupsOfPlan, parseDependencyTable, parsePlanPhases, parseSprintLanes, phaseHeading, phaseHeadingPattern, phaseHeadings, phaseIdsOfCell, phaseIdsOfPlan, phaseLabel, phaseLabels, phaseProgress, phaseWaves, phasesText, phasesWithoutDependencyRow, readPhaseDependencies, readPlanHeader, readSprintDocument, resolvePlanPath, sectionHeading, shortNameFindings, shortTitle, sprintTitleOf, sumPhases, tallyPhases, wavesOfPlan, withoutRepeats };
594
+ export { ADDRESS_FORM, APPOINTMENT_VOCABULARY, APPROVAL_TEXTS, type ChecklistItem, type ColumnModel, type DeferredItem, type DriftFinding, LANE_PROMPT_OPENING, LANGUAGE_NAME, type LaneProgress, type LongPhaseTitle, type MergedPhaseWithOpenBoxes, type MisplacedDeferral, NEXT_VOCABULARY, PHASE_CELL_LIMIT, PHASE_ID_PREFIX, PHASE_TITLE_LIMIT, PHASE_WORK, PLAN_LANGUAGE, PLAN_STATUS, PLAN_VOCABULARY, PROSE_REASON, type PhaseDependency, type PhaseHeading, type PhaseProgress, type PhaseSchedule, type PhaseTally, type PhaseWithoutGate, type PhaseWithoutRow, type PhaseWork, type PhaseWorkFields, type PlanHeader, type PlanLanguage, type PlanPhase, type PlanStatus, type PlanStep, type PlanVocabulary, type PlansByHref, type ProseReason, RESPONSE_ADDRESS, type ResponseAddress, type RowWithProse, type RowWithoutPhase, SELF_MERGE_LABEL, SHORT_NAME_LIMIT, SHORT_NAME_PROBLEM, STANDING_APPROVAL_TEXTS, type ShortNameFinding, type ShortNameProblem, type SprintDocument, type SprintLane, type SprintPlanRow, type SprintPlanTable, type StatusWord, type StepProgress, WAITING_VOCABULARY, activeItem, boardRowsWithProse, boardRowsWithoutPhase, classifyStatus, columnModels, countDecisions, criticalPath, currentPhase, currentPhaseOf, deferralTarget, differsFromColumn, endsPhase, findDrift, hasLanesTable, isCheckboxLine, isDocumentationGateItem, isOpenCheckboxLine, isPhaseFinished, isPhaseOpen, isPlanLanguage, isReviewGateLine, isTickedUnmerged, laneNameOf, laneProgress, longPhaseTitles, mergedPhasesWithOpenBoxes, misplacedDeferrals, newPhasesWithoutReviewGate, onlyDocumentationOpen, parallelGroups, parallelGroupsOfPlan, parseDependencyTable, parsePlanPhases, parseSprintLanes, phaseHeading, phaseHeadingPattern, phaseHeadings, phaseIdsOfCell, phaseIdsOfPlan, phaseLabel, phaseLabels, phaseProgress, phaseWaves, phaseWorkFields, phasesText, phasesWithoutDependencyRow, readPhaseDependencies, readPlanHeader, readSprintDocument, resolvePlanPath, sectionHeading, shortNameFindings, shortTitle, sprintTitleOf, sumPhases, tallyPhases, wavesOfPlan, withoutRepeats };
@@ -229,6 +229,8 @@ var boardRowsWithProse = (markdown, language, previousLines) => {
229
229
  };
230
230
 
231
231
  // src/plan-format/phases.ts
232
+ var PHASE_WORK = { touched: "touched", merged: "merged" };
233
+ var phaseWorkFields = (work) => work === PHASE_WORK.merged ? { work, touched: true, merged: true } : { work, touched: true };
232
234
  var REVIEW_GATE = /^\s*\*\*review[- ]gate\b/i;
233
235
  var PHASE_END = /^#{1,3}\s/;
234
236
  var CHECKBOX = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/;
@@ -391,8 +393,7 @@ var phaseProgress = (phases, id) => {
391
393
  total: items.length,
392
394
  items,
393
395
  ...phase?.deferred ? { deferred: phase.deferred } : {},
394
- ...phase?.touched ? { touched: true } : {},
395
- ...phase?.merged ? { merged: true } : {}
396
+ ...phase?.work ? phaseWorkFields(phase.work) : {}
396
397
  };
397
398
  };
398
399
  var isPhaseFinished = ({ items }) => items.length > 0 && items.every(({ done }) => done);
@@ -400,7 +401,7 @@ var isPhaseOpen = (phase) => phase.items.length > 0 && !isPhaseFinished(phase);
400
401
  var isWritten = ({ total }) => total > 0;
401
402
  var newestWrittenStepIndex = (steps) => steps.reduce((last, { phases }, index) => phases.some(isWritten) ? index : last, -1);
402
403
  var newestRowIndex = (steps) => steps.reduce((last, { step }, index) => step.withoutRow ? last : index, -1);
403
- var isTouched = (phase) => phase.touched === true && isWritten(phase);
404
+ var isTouched = (phase) => phase.work !== void 0 && isWritten(phase);
404
405
  var isTickedUnmerged = (phase) => isTouched(phase) && isPhaseFinished(phase);
405
406
  var newestStepWith = (steps, pick) => {
406
407
  for (let stepIndex = steps.length - 1; stepIndex >= 0; stepIndex--) {
@@ -913,6 +914,7 @@ export {
913
914
  PHASE_CELL_LIMIT,
914
915
  PHASE_ID_PREFIX,
915
916
  PHASE_TITLE_LIMIT,
917
+ PHASE_WORK,
916
918
  PLAN_LANGUAGE,
917
919
  PLAN_STATUS,
918
920
  PLAN_VOCABULARY,
@@ -966,6 +968,7 @@ export {
966
968
  phaseLabels,
967
969
  phaseProgress,
968
970
  phaseWaves,
971
+ phaseWorkFields,
969
972
  phasesText,
970
973
  phasesWithoutDependencyRow,
971
974
  readPhaseDependencies,
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.12.2"
3
+ version = "1.12.3"
4
4
  min_herdr_version = "0.8.2"
5
5
  description = "Show the herd, the state of a board's lanes, in a herdr pane"
6
6
  platforms = ["linux", "macos"]
package/install.sh CHANGED
@@ -9,7 +9,7 @@ set -eu
9
9
 
10
10
  # The default is the version this file ships with. The release bump changes it together with
11
11
  # package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
12
- JACKHERD_VERSION="${JACKHERD_VERSION:-1.12.2}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.12.3}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.12.2",
3
+ "version": "1.12.3",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -2,6 +2,7 @@ import { closeSync, existsSync, fstatSync, openSync, readdirSync, readSync, stat
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { CLAUDE_DIRECTORY, SESSION_LOGS_DIRECTORY } from '../claude/paths'
5
+ import { boundedMemo } from '../shared/bounded-memo'
5
6
  import { isRecord } from '../shared/is-record'
6
7
 
7
8
  /** A tail read before: the file as it was (inode, size, modification time), its lines and the byte offset each starts at. */
@@ -222,18 +223,15 @@ export const parseLogEntry = (line: string): Record<string, unknown> | null => {
222
223
  /** How many parsed lines `keptLogEntry` holds: the answers and last entries of the few dozen logs the herd follows. */
223
224
  export const KEPT_LOG_ENTRIES = 4096
224
225
 
225
- const keptEntries = new Map<string, Record<string, unknown> | null>()
226
+ const keptEntries = boundedMemo<string, Readonly<Record<string, unknown>> | null>(KEPT_LOG_ENTRIES)
226
227
 
227
228
  /**
228
229
  * `parseLogEntry` kept per line text, for logs read again every few seconds: a line read before is not parsed again,
229
230
  * also when a re-read hands it back as a new string. The entry is shared, so a caller must not change it. Past
230
231
  * `KEPT_LOG_ENTRIES` lines the store starts over.
231
232
  */
232
- export const keptLogEntry = (line: string): Record<string, unknown> | null => {
233
+ export const keptLogEntry = (line: string): Readonly<Record<string, unknown>> | null => {
233
234
  const kept = keptEntries.get(line)
234
235
  if (kept !== undefined) return kept
235
- const entry = parseLogEntry(line)
236
- if (keptEntries.size >= KEPT_LOG_ENTRIES) keptEntries.clear()
237
- keptEntries.set(line, entry)
238
- return entry
236
+ return keptEntries.set(line, parseLogEntry(line))
239
237
  }
@@ -12,19 +12,35 @@ export type DeferredItem = { text: string; target: string }
12
12
  /** An open checkbox whose phase arrow is not at the end of its line (1-based), with the form the parser reads. */
13
13
  export type MisplacedDeferral = { line: number; target: string; corrected: string }
14
14
 
15
+ /** What the worktree did to a phase, against the base branch: its ticks differ (`touched`), and the base already carries a tick of it (`merged`): a run of it was merged. */
16
+ export const PHASE_WORK = { touched: 'touched', merged: 'merged' } as const
17
+
18
+ export type PhaseWork = (typeof PHASE_WORK)[keyof typeof PHASE_WORK]
19
+
20
+ /** `work` with the flags it replaced, which published readers of `PlanPhase` and `PhaseProgress` may still read. */
21
+ export type PhaseWorkFields = {
22
+ work: PhaseWork
23
+ /** @deprecated Read `work`: set exactly when `work` is. Removed in the next minor release. */
24
+ touched?: true
25
+ /** @deprecated Read `work === PHASE_WORK.merged`. Removed in the next minor release. */
26
+ merged?: true
27
+ }
28
+
15
29
  /**
16
- * `touched` is set by the phase source when the worktree's ticks differ from the base branch's, `merged` beside it
17
- * when the base branch already carries a tick of the phase: a run of it was merged. `deferred` holds the checkboxes
18
- * moved to another phase; they are not among `items`, so they count neither as open nor as done.
30
+ * `work` is set by the phase source and says nothing for a phase the worktree left as the base has it; a merged phase
31
+ * is touched as well. `deferred` holds the checkboxes moved to another phase; they are not among `items`, so they
32
+ * count neither as open nor as done.
19
33
  */
20
34
  export type PlanPhase = {
21
35
  id: string
22
36
  title: string
23
37
  items: ChecklistItem[]
24
38
  deferred?: DeferredItem[]
25
- touched?: true
26
- merged?: true
27
- }
39
+ } & Partial<PhaseWorkFields>
40
+
41
+ /** The fields a phase's `work` sets, the deprecated `touched` and `merged` derived from it. */
42
+ export const phaseWorkFields = (work: PhaseWork): PhaseWorkFields =>
43
+ work === PHASE_WORK.merged ? { work, touched: true, merged: true } : { work, touched: true }
28
44
 
29
45
  /** A phase heading with its 1-based line, the lower-case id and the title. */
30
46
  export type PhaseHeading = { line: number; id: string; title: string }
@@ -1,8 +1,8 @@
1
1
  import { type PlanStep, phaseLabel, type SprintLane } from './lanes'
2
- import type { ChecklistItem, DeferredItem, PlanPhase } from './phases'
2
+ import { type ChecklistItem, type DeferredItem, type PhaseWorkFields, type PlanPhase, phaseWorkFields } from './phases'
3
3
 
4
4
  /**
5
- * `title` is null when the sprint names a phase the plan does not have; `touched`, `merged` and `deferred` carry the
5
+ * `title` is null when the sprint names a phase the plan does not have; `work` and `deferred` carry the
6
6
  * plan phase's own, and the deferred checkboxes are in neither `done` nor `total`.
7
7
  */
8
8
  export type PhaseProgress = {
@@ -12,9 +12,7 @@ export type PhaseProgress = {
12
12
  total: number
13
13
  items: ChecklistItem[]
14
14
  deferred?: DeferredItem[]
15
- touched?: true
16
- merged?: true
17
- }
15
+ } & Partial<PhaseWorkFields>
18
16
 
19
17
  export type StepProgress = { step: PlanStep; phases: PhaseProgress[] }
20
18
 
@@ -49,8 +47,7 @@ export const phaseProgress = (phases: readonly PlanPhase[], id: string): PhasePr
49
47
  total: items.length,
50
48
  items,
51
49
  ...(phase?.deferred ? { deferred: phase.deferred } : {}),
52
- ...(phase?.touched ? { touched: true as const } : {}),
53
- ...(phase?.merged ? { merged: true as const } : {}),
50
+ ...(phase?.work ? phaseWorkFields(phase.work) : {}),
54
51
  }
55
52
  }
56
53
 
@@ -75,7 +72,7 @@ const newestWrittenStepIndex = (steps: readonly StepProgress[]): number =>
75
72
  const newestRowIndex = (steps: readonly StepProgress[]): number =>
76
73
  steps.reduce((last, { step }, index) => (step.withoutRow ? last : index), -1)
77
74
 
78
- const isTouched = (phase: PhaseProgress): boolean => phase.touched === true && isWritten(phase)
75
+ const isTouched = (phase: PhaseProgress): boolean => phase.work !== undefined && isWritten(phase)
79
76
 
80
77
  /**
81
78
  * A phase the lane ticked to the end in its worktree while the base branch does not carry those ticks yet: still
@@ -9,6 +9,7 @@ import { mainCheckout, worktreeRoot } from './git'
9
9
  import { fetchPullRequests } from './github'
10
10
  import { claimOwnTerminal, startExclusive } from './herd-claim'
11
11
  import { recallLanes, rememberLanes } from './herd-memory'
12
+ import { publishedKey } from './herd-state'
12
13
  import { runLive } from './live'
13
14
  import { parseOptions } from './options'
14
15
  import { isSupervisedChild } from './restart-decision'
@@ -25,7 +26,11 @@ const printOnce = async (source: Source): Promise<void> => {
25
26
  const memories = recallLanes(source.root)
26
27
  const snapshot = await loadSnapshot(source, await fetchPullRequests(source.root), memories)
27
28
  rememberLanes(source.root, memories, snapshot.documents.laneOrder)
28
- const screen = renderSnapshot(snapshot, startSelection(snapshot, source.options), snapshot.errors, paneWidth())
29
+ const screen = renderSnapshot(snapshot, {
30
+ selection: startSelection(snapshot, source.options),
31
+ errors: snapshot.errors,
32
+ width: paneWidth(),
33
+ })
29
34
  console.log(frameText(screen, Boolean(process.stdout.isTTY)))
30
35
  }
31
36
 
@@ -59,8 +64,10 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
59
64
  }
60
65
  const source: Source = { root, sprintFile: picked.file, options, settings, terminal, ...agentListOf(settings) }
61
66
  if (options.once || !process.stdout.isTTY) await printOnceWithNotice(source, picked.notice)
62
- else if (isSupervisedChild(process.env)) await startExclusive(claimOwnTerminal, () => runLive(source))
63
- else process.exit(await superviseView(args, await startedKit()))
67
+ else if (isSupervisedChild(process.env)) {
68
+ const kit = await startedKit()
69
+ await startExclusive(claimOwnTerminal, () => runLive(source, kit && publishedKey(source, kit)))
70
+ } else process.exit(await superviseView(args, await startedKit()))
64
71
  return 0
65
72
  } catch (error) {
66
73
  console.error(error instanceof Error ? error.message : String(error))
@@ -1,5 +1,6 @@
1
1
  import { keptLogEntry } from '../context/session-log'
2
2
  import type { PlanPhase } from '../plan-format'
3
+ import { boundedMemo } from '../shared/bounded-memo'
3
4
  import { isRecord } from '../shared/is-record'
4
5
 
5
6
  /**
@@ -67,16 +68,11 @@ const wordsOf = (text: string): Set<string> => new Set(text.toLowerCase().match(
67
68
  /** How many task texts keep their words: every open task of the plans a herd shows, a few thousand at most. */
68
69
  const KEPT_TASK_WORDS = 8192
69
70
 
70
- const taskWords = new Map<string, string[]>()
71
+ const taskWords = boundedMemo<string, string[]>(KEPT_TASK_WORDS)
71
72
 
72
73
  /** The distinctive words of a task's text (its first `TASK_TEXT_LENGTH` characters), kept per text across refreshes. */
73
74
  const taskWordsOf = (text: string): string[] => {
74
- const kept = taskWords.get(text)
75
- if (kept) return kept
76
- const words = [...wordsOf(text.slice(0, TASK_TEXT_LENGTH))]
77
- if (taskWords.size >= KEPT_TASK_WORDS) taskWords.clear()
78
- taskWords.set(text, words)
79
- return words
75
+ return taskWords.get(text) ?? taskWords.set(text, [...wordsOf(text.slice(0, TASK_TEXT_LENGTH))])
80
76
  }
81
77
 
82
78
  /**
@@ -0,0 +1,80 @@
1
+ import { fit, paint } from '../terminal/text'
2
+ import { type AgentOf, clickHint } from './click-hint'
3
+ import type { Screen } from './layout'
4
+ import { blockRowByKey, boldBracketAt, type FocusHit, focusLine, isFocused, LINKS_KEY_PREFIX } from './row-focus'
5
+ import type { Selection } from './selection'
6
+ import { TEXTS } from './texts'
7
+
8
+ const NO_ROW = '\0'
9
+ const LEAD = /^ */
10
+
11
+ /** A selection that focuses no row: the screen drawn with it is the base `focusedScreen` lays the focus on. */
12
+ export const unfocused = (selection: Selection): Selection => ({
13
+ ...selection,
14
+ selected: NO_ROW,
15
+ hovered: null,
16
+ hoveredFrom: null,
17
+ })
18
+
19
+ /** The blanks a block puts before a row it holds (`indented`): the focus lies right of them, as narrow as the row. */
20
+ const leadOf = (line: string): number => LEAD.exec(line)?.[0].length ?? 0
21
+
22
+ /** `lines` with the lines of `hit` (counted from `offset`) changed by `change`, the first line told by its index. */
23
+ const changedHit = (
24
+ lines: readonly string[],
25
+ offset: number,
26
+ hit: FocusHit | undefined,
27
+ change: (line: string, index: number) => string,
28
+ ): string[] =>
29
+ lines.map((line, at) => {
30
+ const index = hit ? at + offset - hit.line : -1
31
+ return hit && index >= 0 && index < hit.lines ? change(line, index) : line
32
+ })
33
+
34
+ /**
35
+ * `screen` with the focus `state` gives laid on one region of `lines`: the focused row on the focus background and
36
+ * mark, a line of links under the mouse marked alone, and the square-bracket button under the mouse bold.
37
+ */
38
+ const focusedLines = (lines: readonly string[], offset: number, screen: Screen, state: Selection, width: number) => {
39
+ const hitOf = (key: string | null | undefined) => (key ? screen.focusHits.find((hit) => hit.key === key) : undefined)
40
+ const hovered = hitOf(state.hovered)
41
+ const focusedKey = state.selected !== null && isFocused(state, state.selected) ? state.selected : null
42
+ const focusedHit = hitOf(focusedKey)
43
+ const gutter = focusedHit ? leadOf(lines[focusedHit.line - offset] ?? '') : 0
44
+ const focused = changedHit(
45
+ lines,
46
+ offset,
47
+ focusedHit,
48
+ (line, index) => `${line.slice(0, gutter)}${focusLine(line.slice(gutter), width - gutter, index === 0)}`,
49
+ )
50
+ const links = state.hovered?.startsWith(LINKS_KEY_PREFIX)
51
+ ? changedHit(focused, offset, hovered, (line, index) => (index === 0 ? focusLine(line, width, true) : line))
52
+ : focused
53
+ const from = state.hoveredFrom
54
+ return from == null
55
+ ? links
56
+ : changedHit(links, offset, hovered, (line, index) => (index === 0 ? boldBracketAt(line, from) : line))
57
+ }
58
+
59
+ /**
60
+ * The screen a full drawing with `state` gives, laid on `plain` (drawn with `unfocused`) without a new layout: a hover
61
+ * only moves the focus. Null where it needs the layout: with the help open, or for a row of the blocks on top, whose
62
+ * buttons stand only at the selection. `agentOf` says a lane's agent, for the footer's click hint.
63
+ */
64
+ export const focusedScreen = (plain: Screen, state: Selection, width: number, agentOf: AgentOf): Screen | null => {
65
+ const blocks = [plain.yourTurnBlock, plain.sourceBlock]
66
+ const isBlockRow = (key: string | null | undefined) => !!key && blockRowByKey(blocks, key) !== null
67
+ if (state.showLegend || isBlockRow(state.selected) || isBlockRow(state.hovered)) return null
68
+ const header = focusedLines(plain.header, 0, plain, state, width)
69
+ const lines = focusedLines(plain.body.lines, plain.header.length, plain, state, width)
70
+ const selectedHit = plain.focusHits.find(({ key }) => key === state.selected)
71
+ const cursorLine =
72
+ selectedHit && selectedHit.line >= plain.header.length ? selectedHit.line - plain.header.length : -1
73
+ const hint = clickHint(state, (key) => blockRowByKey(blocks, key), agentOf) ?? TEXTS.hint
74
+ return {
75
+ ...plain,
76
+ header,
77
+ body: { ...plain.body, lines, cursorLine },
78
+ footer: [...plain.footer.slice(0, -1), fit(paint.dim(hint), width)],
79
+ }
80
+ }
package/src/plans/git.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs'
2
2
  import { dirname } from 'node:path'
3
- import type { LaneStatus } from './layout'
4
3
  import { createProcessSlots } from './process-slots'
5
4
  import type { BaseRefInfo } from './sprint-page'
6
- import type { BranchStatus } from './status-pace'
5
+ import type { BranchStatus, WorktreeStatus } from './status-pace'
7
6
 
8
7
  /** How many git processes the herd's reads keep in flight; more end together and hold the event loop as one block. */
9
8
  const GIT_PROCESSES = 6
@@ -176,33 +175,29 @@ export const branchStatusOf = (output: string | null): BranchStatus => {
176
175
  }
177
176
  }
178
177
 
179
- /** Reads branch and changed files of a worktree; its git process starts after `pass`. */
180
- export type BranchStatusReader = (worktree: string, pass: () => Promise<void>) => Promise<BranchStatus>
181
-
182
178
  /**
183
179
  * Branch and changed files of a worktree from `git status`, the dearest of the herd's git calls in a large checkout.
184
180
  * Without optional locks: the herd never rewrites the index, which would take the lock an agent's git needs and move
185
181
  * the index the worktree's stamp watches (`worktreeStamp`).
186
182
  */
187
- export const branchStatusAt: BranchStatusReader = async (worktree, pass) =>
183
+ const branchStatusAt = async (worktree: string, pass: () => Promise<void>): Promise<BranchStatus> =>
188
184
  branchStatusOf(await gitAsync(worktree, ['--no-optional-locks', 'status', '--porcelain=v2', '--branch'], pass))
189
185
 
190
186
  /**
191
187
  * Branch, commits ahead of `baseRef` and changed files of a worktree; null and 0 when it does not exist. The two
192
188
  * git calls run side by side and without holding the event loop, so a key pressed meanwhile is answered at once;
193
- * each starts after `pass` (a `SliceGate`), so the starts of many worktrees' calls do not add up to one block.
194
- * `readBranchStatus` may answer from what it kept instead of asking git (`pacedStatus`).
189
+ * each starts after `pass` (a `SliceGate`), so the starts of many worktrees' calls do not add up to one block. The
190
+ * herd keeps the answer while nothing moved (`pacedStatus`).
195
191
  */
196
192
  export const gitStatusOf = async (
197
193
  worktree: string,
198
194
  baseRef: string,
199
195
  pass: () => Promise<void> = passAtOnce,
200
- readBranchStatus: BranchStatusReader = branchStatusAt,
201
- ): Promise<Pick<LaneStatus, 'branch' | 'ahead' | 'dirty'>> => {
196
+ ): Promise<WorktreeStatus> => {
202
197
  if (!existsSync(worktree)) return { branch: null, ahead: null, dirty: 0 }
203
198
  const [count, status] = await Promise.all([
204
199
  gitAsync(worktree, ['rev-list', '--count', `${baseRef}..HEAD`], pass),
205
- readBranchStatus(worktree, pass),
200
+ branchStatusAt(worktree, pass),
206
201
  ])
207
202
  const ahead = Number.parseInt(count ?? '', 10)
208
203
  return { ...status, ahead: Number.isNaN(ahead) ? null : ahead }