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.
- package/CHANGELOG.md +11 -0
- package/README.md +16 -16
- package/dist/plan-format.d.ts +23 -11
- package/dist/plan-format.js +6 -3
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/package.json +1 -1
- package/src/context/session-log.ts +4 -6
- package/src/plan-format/phases.ts +22 -6
- package/src/plan-format/progress.ts +5 -8
- package/src/plans/command.ts +10 -3
- package/src/plans/current-task.ts +3 -7
- package/src/plans/focus-overlay.ts +80 -0
- package/src/plans/git.ts +6 -11
- package/src/plans/herd-state.ts +201 -0
- package/src/plans/lane-reason.ts +1 -1
- package/src/plans/lane-summary.ts +6 -2
- package/src/plans/layout-phase.ts +1 -3
- package/src/plans/layout-summary.ts +1 -1
- package/src/plans/layout.ts +32 -31
- package/src/plans/link-chips.ts +7 -16
- package/src/plans/live.ts +215 -267
- package/src/plans/markdown-text.ts +4 -0
- package/src/plans/page-sync.ts +39 -0
- package/src/plans/phase-source.ts +5 -2
- package/src/plans/reader.ts +217 -0
- package/src/plans/refresh-schedule.ts +53 -0
- package/src/plans/snapshot.ts +32 -31
- package/src/plans/status-pace.ts +7 -4
- package/src/plans/subagents-read.ts +28 -12
- package/src/plans/supervisor.ts +1 -1
- package/src/plans/view.ts +9 -101
- package/src/plans/waiting-click.ts +27 -17
- package/src/plans/worked-phases.ts +1 -1
- package/src/plans/worktree-stamp.ts +12 -9
- package/src/plans/your-turn-rows.ts +33 -32
- package/src/plans/your-turn.ts +6 -4
- package/src/shared/bounded-memo.ts +19 -0
- package/src/shared/stat-cache.ts +2 -1
- package/src/terminal/lifetime.ts +52 -0
- package/src/watch/command.ts +6 -0
- package/src/watch/herd-publisher.ts +84 -0
- package/src/watch/source-entries.ts +7 -0
- package/src/watch/source-statuses.ts +2 -2
- package/src/watch/sources.ts +5 -2
- 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
|
+
<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
|
-
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.
|
|
6
|
+
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
| --- | --- |
|
package/dist/plan-format.d.ts
CHANGED
|
@@ -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
|
-
* `
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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
|
-
|
|
213
|
-
|
|
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; `
|
|
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
|
-
|
|
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 };
|
package/dist/plan-format.js
CHANGED
|
@@ -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?.
|
|
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.
|
|
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,
|
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.
|
|
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
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
* `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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; `
|
|
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
|
-
|
|
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?.
|
|
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.
|
|
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
|
package/src/plans/command.ts
CHANGED
|
@@ -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,
|
|
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))
|
|
63
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
200
|
+
branchStatusAt(worktree, pass),
|
|
206
201
|
])
|
|
207
202
|
const ahead = Number.parseInt(count ?? '', 10)
|
|
208
203
|
return { ...status, ahead: Number.isNaN(ahead) ? null : ahead }
|