jackherd 1.18.2 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/INSTALL.md +1 -1
- package/README.md +30 -25
- package/assets/icons/model.svg +1 -0
- package/dist/{chunk-N5FAOQZL.js → chunk-JDO6O3UO.js} +52 -2
- package/dist/config.d.ts +15 -2
- package/dist/config.js +22 -3
- package/dist/plan-format.d.ts +84 -1
- package/dist/plan-format.js +85 -1
- package/docs/plan-contract.md +8 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +10 -11
- package/kit/hooks/hooks/approval-gate/run.ts +23 -3
- package/kit/hooks/hooks/browser-guard/decision.ts +7 -0
- package/kit/hooks/hooks/browser-guard/index.ts +50 -0
- package/kit/hooks/hooks/plain-question/arguments.ts +14 -1
- package/kit/hooks/hooks/plain-question/card.ts +102 -0
- package/kit/hooks/hooks/plain-question/form.ts +25 -9
- package/kit/hooks/hooks/plain-question/index.ts +20 -25
- package/kit/hooks/hooks/plain-question/recommendation.ts +6 -6
- package/kit/hooks/hooks/return-brief/decision.ts +29 -0
- package/kit/hooks/hooks/return-brief/index.ts +37 -0
- package/kit/hooks/hooks/return-brief/settings.ts +11 -0
- package/kit/hooks/hooks/shared/coordinator-call.ts +25 -0
- package/kit/hooks/hooks/shared/transcript.ts +9 -0
- package/kit/hooks/hooks.json +18 -1
- package/kit/review/agents/security-audit.md +1 -1
- package/kit/workflow/agents/browser-check.md +29 -0
- package/kit/workflow/skills/implement/SKILL.md +5 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +36 -14
- package/kit/workflow/skills/orchestrate/references/acceptance.md +10 -9
- package/kit/workflow/skills/orchestrate/references/coordinator-clear.md +45 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +15 -12
- package/kit/workflow/skills/plan/SKILL.md +2 -0
- package/kit/workflow/skills/plan/references/template-English.md +8 -0
- package/kit/workflow/skills/plan/references/template-German.md +8 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +16 -0
- package/src/brief/command.ts +45 -0
- package/src/brief/lane-brief.ts +51 -0
- package/src/brief/lane-facts.ts +64 -0
- package/src/check/skill-budget-defaults.ts +13 -2
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +22 -0
- package/src/lane-prompt/texts.ts +2 -2
- package/src/plan-contract/command.ts +1 -0
- package/src/plan-contract/contract.ts +28 -5
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/plan-head.ts +131 -0
- package/src/plans/active-tasks.ts +8 -1
- package/src/plans/click-hint.ts +2 -1
- package/src/plans/focus-overlay.ts +5 -2
- package/src/plans/folded-detail.ts +47 -0
- package/src/plans/lane-line.ts +86 -0
- package/src/plans/lane-reason.ts +24 -4
- package/src/plans/layout-phase.ts +4 -20
- package/src/plans/layout-summary.ts +56 -45
- package/src/plans/layout.ts +178 -44
- package/src/plans/port-listening.ts +25 -0
- package/src/plans/selection.ts +11 -0
- package/src/plans/snapshot.ts +27 -9
- package/src/plans/task-checklist.ts +57 -0
- package/src/plans/texts.ts +34 -9
- package/src/resume/command.ts +40 -6
- package/src/resume/coordinator-read.ts +136 -0
- package/src/resume/state.ts +163 -0
- package/src/resume/texts.ts +6 -0
- package/src/sync/render.ts +5 -1
- package/src/vocabulary/index.ts +74 -0
- package/src/watch/clear-moment.ts +228 -0
- package/src/watch/command.ts +92 -8
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/reference-paths.ts +8 -4
- package/src/watch/watch-events.ts +13 -0
package/src/plans/layout.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TONE, TONE_ROLE } from '../config/tones'
|
|
2
2
|
import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
|
|
3
|
-
import type { LaneProgress, PhaseProgress } from '../plan-format'
|
|
3
|
+
import type { LaneProgress, PhaseProgress, PlanStep } from '../plan-format'
|
|
4
4
|
import { currentPhase, isPhaseOpen, phaseLabel } from '../plan-format'
|
|
5
5
|
import { ellipsize } from '../plan-format/terminal-text'
|
|
6
6
|
import type { WaitingItem } from '../plan-format/waiting'
|
|
@@ -36,8 +36,10 @@ import type { UnplannedTask } from './active-tasks'
|
|
|
36
36
|
import type { SubagentLine } from './activity-read'
|
|
37
37
|
import { type AgentOf, clickHint } from './click-hint'
|
|
38
38
|
import { type CoordinatorView, coordinatorShortName, coordinatorText } from './coordinator-row'
|
|
39
|
+
import { type FoldedDetailParts, foldedDetail } from './folded-detail'
|
|
39
40
|
import { type Frame, indented, joinFrames, narrower, rowFrame, spaced, textFrame, unpainted } from './frame'
|
|
40
41
|
import { groomedText } from './groomed'
|
|
42
|
+
import { laneLead, portBadge } from './lane-line'
|
|
41
43
|
import {
|
|
42
44
|
LANE_REASON,
|
|
43
45
|
type LaneReason,
|
|
@@ -60,7 +62,7 @@ import {
|
|
|
60
62
|
STILL_TASK,
|
|
61
63
|
type TaskStyle,
|
|
62
64
|
} from './layout-phase'
|
|
63
|
-
import { foldedTaskLine, openLaneFrame } from './layout-summary'
|
|
65
|
+
import { foldedTaskLine, openLaneFrame, reportedTexts } from './layout-summary'
|
|
64
66
|
import { legendLines } from './legend'
|
|
65
67
|
import { chipKey, type LinkChip, planUrlOf } from './link-chips'
|
|
66
68
|
import { plain } from './markdown-text'
|
|
@@ -112,6 +114,7 @@ import {
|
|
|
112
114
|
statusOnly,
|
|
113
115
|
} from './source-rows'
|
|
114
116
|
import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord, TURN_MARK } from './symbols'
|
|
117
|
+
import { shortTitle } from './task-name'
|
|
115
118
|
import { BADGE_TEXTS, type BadgeTexts, checksText, foldedChecksText, laneMarkText, TEXTS } from './texts'
|
|
116
119
|
import { paintOfRole, paintOfTone, toneOfRole } from './tones'
|
|
117
120
|
import {
|
|
@@ -158,6 +161,10 @@ export type LaneView = {
|
|
|
158
161
|
restNote?: string | null
|
|
159
162
|
/** The last action of the lane's agent and of its running subagents, from their logs; absent without a source. */
|
|
160
163
|
activity?: LaneActivity
|
|
164
|
+
/** True while a server listens on the lane's port (`isPortListening`); absent or false shows no port badge. */
|
|
165
|
+
listening?: boolean
|
|
166
|
+
/** The ids of every `### Phase` heading of each plan the lane names, by plan link in plan order: the `y` of `P2/5`. */
|
|
167
|
+
planPhaseIds?: Record<string, string[]>
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
/** `lane` is null while the agent's log holds no action; the lines show only while the lane works. */
|
|
@@ -204,6 +211,8 @@ export type Screen = {
|
|
|
204
211
|
focusHits: FocusHit[]
|
|
205
212
|
/** The keys of the rows the arrows walk, in screen order: the rows of the blocks on top, then those of the body. */
|
|
206
213
|
rowKeys: string[]
|
|
214
|
+
/** The keys of the folded lanes whose step row shows only with the focus: the screen without one lacks that row. */
|
|
215
|
+
stepRowKeys: string[]
|
|
207
216
|
}
|
|
208
217
|
|
|
209
218
|
/**
|
|
@@ -669,12 +678,6 @@ const headPhase = (view: LaneView): PhaseProgress | null => {
|
|
|
669
678
|
return signalled ?? latest ?? (current && isPhaseOpen(current) ? current : null)
|
|
670
679
|
}
|
|
671
680
|
|
|
672
|
-
/** `P9 Release 1.0` for the phase the lane line names; empty when there is none. */
|
|
673
|
-
const nextPhaseLabel = (view: LaneView): string => {
|
|
674
|
-
const phase = headPhase(view)
|
|
675
|
-
return phase ? `${phaseLabel(phase.id)}${phase.title ? ` ${plain(phase.title)}` : ''}` : ''
|
|
676
|
-
}
|
|
677
|
-
|
|
678
681
|
const markTone = ({ feedback, changesRequested }: PullRequestBadges): BadgeTone => {
|
|
679
682
|
if (changesRequested) return BADGE_TONE.red
|
|
680
683
|
return feedback > 0 ? BADGE_TONE.amber : BADGE_TONE.grey
|
|
@@ -706,21 +709,28 @@ const ticketMark = (tickets: LaneTickets | undefined): string[] => {
|
|
|
706
709
|
return [linkedBox(first.url, `${TICKET_MARK} ${tickets.newCount}`, BADGE_TONE.amber)]
|
|
707
710
|
}
|
|
708
711
|
|
|
709
|
-
/**
|
|
710
|
-
const
|
|
711
|
-
[...pullRequests.map((pullRequest) => pullRequestMark(pullRequest, badges ?? {})), ...ticketMark(tickets)].join(' ')
|
|
712
|
+
/** The lane's port badge while its server listens; empty without a port or a listener. Only an open lane draws it. */
|
|
713
|
+
const lanePortBadge = ({ progress, listening }: LaneView): string => portBadge(progress.lane.port, listening === true)
|
|
712
714
|
|
|
713
715
|
/**
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
+
* The click target over the cells of the lane's port badge on `line`, a line of its own frame, so a click on the badge
|
|
717
|
+
* opens the server and does not fold the lane; none while the badge is not on the line.
|
|
716
718
|
*/
|
|
717
|
-
const
|
|
718
|
-
const
|
|
719
|
-
|
|
720
|
-
const
|
|
721
|
-
|
|
719
|
+
const portTargets = (view: LaneView, line: string): ClickTarget[] => {
|
|
720
|
+
const { port } = view.progress.lane
|
|
721
|
+
const badgeText = lanePortBadge(view)
|
|
722
|
+
const start = badgeText ? line.indexOf(badgeText) : -1
|
|
723
|
+
if (port === null || start < 0) return []
|
|
724
|
+
const from = visibleLength(line.slice(0, start))
|
|
725
|
+
return [{ line: 0, from, to: from + visibleLength(badgeText), key: chipKey(`http://localhost:${port}`) }]
|
|
722
726
|
}
|
|
723
727
|
|
|
728
|
+
/** A folded lane's marks: its pull request badges, and the ticket mark while a ticket is new. */
|
|
729
|
+
const laneMarks = ({ pullRequests, badges, tickets }: LaneView): string =>
|
|
730
|
+
[...pullRequests.map((pullRequest) => pullRequestMark(pullRequest, badges ?? {})), ...ticketMark(tickets)]
|
|
731
|
+
.filter(Boolean)
|
|
732
|
+
.join(' ')
|
|
733
|
+
|
|
724
734
|
/** A lane whose agent rests (idle or done): the folded line says what it waits on, and its phase without the bar. */
|
|
725
735
|
const restsFolded = ({ status }: LaneView, open: boolean): boolean => !open && isRestingAgent(status.agent)
|
|
726
736
|
|
|
@@ -735,25 +745,98 @@ const reasonText = (reason: LaneReason | null): string => {
|
|
|
735
745
|
return `${label} ${reason.text}`
|
|
736
746
|
}
|
|
737
747
|
|
|
738
|
-
/**
|
|
739
|
-
const
|
|
748
|
+
/** The plan step the lane line names: the one of its running phase, else its current one, else its first with a plan. */
|
|
749
|
+
const headStep = (view: LaneView): PlanStep | undefined => {
|
|
750
|
+
const { lane, current } = view.progress
|
|
751
|
+
const stepIndex = headActive(view)?.stepIndex ?? current?.stepIndex
|
|
752
|
+
const step = stepIndex === undefined ? undefined : lane.steps[stepIndex]
|
|
753
|
+
return step?.planHref ? step : lane.steps.find(({ planHref }) => planHref)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** `P2/5` for the phase's place among the plan's phase headings; the phase's label alone while the plan's list is unknown. */
|
|
757
|
+
const phaseCountLabel = (view: LaneView, phase: PhaseProgress, href: string): string => {
|
|
758
|
+
const ids = view.planPhaseIds?.[href] ?? []
|
|
759
|
+
const place = ids.indexOf(phase.id)
|
|
760
|
+
return place < 0 ? phaseLabel(phase.id) : `${phaseLabel(String(place + 1))}/${ids.length}`
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* The plan and the phase a folded lane line names: the plan's short title, `P2/5` from the plan's phase headings (not
|
|
765
|
+
* from its boxes), the phase name with `+n` for the other running phases. Null while the lane names no phase or its
|
|
766
|
+
* plan cannot be told, which the line then answers with the branch.
|
|
767
|
+
*/
|
|
768
|
+
const planParts = (
|
|
769
|
+
view: LaneView,
|
|
770
|
+
): Pick<FoldedDetailParts, 'plan' | 'phaseCount' | 'phaseName' | 'phaseMore'> | null => {
|
|
740
771
|
const phase = headPhase(view)
|
|
741
|
-
|
|
772
|
+
const step = headStep(view)
|
|
773
|
+
if (!phase || !step?.planHref) return null
|
|
774
|
+
const more = summaryOf(view).active.filter((active) => active.phase.id !== phase.id).length
|
|
775
|
+
const name = phase.title ? plain(phase.title) : ''
|
|
776
|
+
return {
|
|
777
|
+
plan: plain(shortTitle(step.planTitle)).trim(),
|
|
778
|
+
phaseCount: phaseCountLabel(view, phase, step.planHref),
|
|
779
|
+
phaseName: name,
|
|
780
|
+
phaseMore: more > 0 ? TEXTS.morePhases(more) : '',
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Whether the folded lane line knows the plan of its lane; a lane that does not shows its reported step all along. */
|
|
785
|
+
const namesPlan = (view: LaneView): boolean => planParts(view) !== null
|
|
786
|
+
|
|
787
|
+
/** `⎇ branch` dim in place of plan and phase for a lane whose plan is not known; empty without a worktree. */
|
|
788
|
+
const branchPart = ({ status }: LaneView): string => (status.branch ? paint.dim(`${BRANCH_MARK} ${status.branch}`) : '')
|
|
789
|
+
|
|
790
|
+
/** The step a running lane is on: the first task it reports; empty while it reports none. */
|
|
791
|
+
const stepOf = (view: LaneView): string => reportedTexts(headActive(view), summaryOf(view), TEXTS.herd)[0] ?? ''
|
|
792
|
+
|
|
793
|
+
/** The status a folded lane line opens with and the words behind it (`laneLead`); empty for a lane without a state. */
|
|
794
|
+
const foldedStatus = (view: LaneView, reason: LaneReason | null, maxText: number): string =>
|
|
795
|
+
laneLead({
|
|
796
|
+
agent: view.status.agent,
|
|
797
|
+
reason,
|
|
798
|
+
minutesAgo: headActive(view)?.minutesAgo ?? null,
|
|
799
|
+
step: stepOf(view),
|
|
800
|
+
maxText,
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
/** The cells around the status text: the `·` before it and the one between status and phase count. */
|
|
804
|
+
const STATUS_GAPS = 6
|
|
805
|
+
|
|
806
|
+
/** The status of a folded lane line with its text cut to what `room` leaves once the phase count stands whole. */
|
|
807
|
+
const statusWithin = (view: LaneView, reason: LaneReason | null, room: number, countWidth: number): string => {
|
|
808
|
+
const bare = visibleLength(foldedStatus(view, reason, 0))
|
|
809
|
+
return foldedStatus(view, reason, room - bare - STATUS_GAPS - countWidth)
|
|
742
810
|
}
|
|
743
811
|
|
|
744
812
|
/**
|
|
745
|
-
* What follows the status on a lane line: the marker
|
|
746
|
-
*
|
|
747
|
-
* is
|
|
813
|
+
* What follows the status on a lane line: the marker and the reason a resting lane rests; folded, the plan, the phase
|
|
814
|
+
* `P2/5 Name` and the reason after them (a resting lane's plan and phase dim), the branch in place of plan and phase
|
|
815
|
+
* when the plan is unknown. Wider than `room`, the plan title gives way first and the reason last (`foldedDetail`).
|
|
748
816
|
*/
|
|
749
817
|
const laneDetail = (view: LaneView, open: boolean, reason: LaneReason | null, room: number): string => {
|
|
750
818
|
const markText = laneMarkText(view)
|
|
751
819
|
const marker = markText ? paint.yellow(markText) : ''
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
const
|
|
756
|
-
|
|
820
|
+
const lead = [agentLabel(view.status.agent), marker].filter(Boolean).join(' ')
|
|
821
|
+
if (open) return [lead, reasonText(reason)].filter(Boolean).join(' ')
|
|
822
|
+
const resting = restsFolded(view, open)
|
|
823
|
+
const dimmed = (text: string) => (resting && text ? paint.dim(text) : text)
|
|
824
|
+
const parts = planParts(view)
|
|
825
|
+
const status = statusWithin(view, reason, room, visibleLength(parts?.phaseCount ?? ''))
|
|
826
|
+
const where = parts
|
|
827
|
+
? {
|
|
828
|
+
...parts,
|
|
829
|
+
plan: parts.plan && paint.dim(parts.plan),
|
|
830
|
+
phaseCount: dimmed(parts.phaseCount),
|
|
831
|
+
phaseName: dimmed(parts.phaseName),
|
|
832
|
+
phaseMore: dimmed(parts.phaseMore),
|
|
833
|
+
}
|
|
834
|
+
: { plan: branchPart(view), phaseCount: '', phaseName: '', phaseMore: '' }
|
|
835
|
+
if (status) {
|
|
836
|
+
const behind = where.plan || where.phaseCount ? paint.dim(' ·') : ''
|
|
837
|
+
return foldedDetail({ ...where, lead: `${[marker, status].filter(Boolean).join(' ')}${behind}`, reason: '' }, room)
|
|
838
|
+
}
|
|
839
|
+
return foldedDetail({ lead, ...where, reason: reasonText(reason) }, room)
|
|
757
840
|
}
|
|
758
841
|
|
|
759
842
|
/**
|
|
@@ -837,8 +920,39 @@ const markedRight = (view: LaneView, columns: LaneColumns): string =>
|
|
|
837
920
|
const markedRightTight = (view: LaneView, columns: LaneColumns): string =>
|
|
838
921
|
marksBeforeRight(view, columns, (right) => (right ? Math.max(columns.right - visibleLength(right), 0) : 0))
|
|
839
922
|
|
|
840
|
-
/**
|
|
841
|
-
const
|
|
923
|
+
/** `markedRight` without any padding: the last resort of a lane whose detail needs the room the alignment would take. */
|
|
924
|
+
const markedRightBare = (view: LaneView, columns: LaneColumns): string => marksBeforeRight(view, columns, () => 0)
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* When no right column leaves room for the whole detail: the bare one while the detail still stands beside it, so
|
|
928
|
+
* the plan gives way and not the question and the phase count, else the tight one, which sends it to a line of its own.
|
|
929
|
+
*/
|
|
930
|
+
const roomierFallback = (head: string, aligned: string, roomier: string[], width: number, indent: string): string => {
|
|
931
|
+
const bare = roomier.at(-1) ?? aligned
|
|
932
|
+
return detailFitsBeside(head, bare, width, indent) ? bare : (roomier[1] ?? aligned)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** Whether the row of the lane has the focus, by the arrows or the mouse. */
|
|
936
|
+
const hasFocus = ({ selected, hovered }: Selection, lane: string): boolean =>
|
|
937
|
+
selected === laneKey(lane) || hovered === laneKey(lane)
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* The task row below a folded lane's line, under the lane's name: only while the lane has the focus (the mouse on it),
|
|
941
|
+
* or always for a lane whose plan is not known; none while the lane reports no task.
|
|
942
|
+
*/
|
|
943
|
+
const foldedTaskRow = (
|
|
944
|
+
view: LaneView,
|
|
945
|
+
indent: string,
|
|
946
|
+
{ width, style, state }: { width: number; style: TaskStyle; state: Selection },
|
|
947
|
+
): string[] =>
|
|
948
|
+
namesPlan(view) && !hasFocus(state, view.progress.lane.name) ? [] : taskLineOf(view, indent, { width, style })
|
|
949
|
+
|
|
950
|
+
/** The task line of a folded lane, whether or not it is shown now; none while the lane reports no task. */
|
|
951
|
+
const taskLineOf = (
|
|
952
|
+
view: LaneView,
|
|
953
|
+
indent: string,
|
|
954
|
+
{ width, style }: { width: number; style: TaskStyle },
|
|
955
|
+
): string[] => {
|
|
842
956
|
const phase = headPhase(view)
|
|
843
957
|
const summary = summaryOf(view)
|
|
844
958
|
const active = summary.active.find((each) => each.phase.id === phase?.id)
|
|
@@ -853,7 +967,7 @@ const foldedTaskRow = (view: LaneView, indent: string, width: number, style: Tas
|
|
|
853
967
|
const laneLines = (
|
|
854
968
|
view: LaneView,
|
|
855
969
|
open: boolean,
|
|
856
|
-
{ width, taskStyle }: Pick<LanesContext, 'width' | 'taskStyle'>,
|
|
970
|
+
{ width, taskStyle, state }: Pick<LanesContext, 'width' | 'taskStyle' | 'state'>,
|
|
857
971
|
columns: LaneColumns,
|
|
858
972
|
reason: LaneReason | null,
|
|
859
973
|
): string[] => {
|
|
@@ -863,10 +977,14 @@ const laneLines = (
|
|
|
863
977
|
const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
|
|
864
978
|
const nameIndent = ' '.repeat(visibleLength(head) - visibleLength(cells))
|
|
865
979
|
const aligned = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
|
|
866
|
-
const
|
|
980
|
+
const wanted = open ? 0 : visibleLength(laneDetail(view, open, reason, width * 2))
|
|
981
|
+
const fitsWhole = (right: string) =>
|
|
982
|
+
detailFitsBeside(head, right, width, nameIndent) && detailPlacement(head, right, width, nameIndent).room >= wanted
|
|
983
|
+
const roomier = [aligned, markedRightTight(view, columns), markedRightBare(view, columns)]
|
|
984
|
+
const right = open ? aligned : (roomier.find(fitsWhole) ?? roomierFallback(head, aligned, roomier, width, nameIndent))
|
|
867
985
|
const { room } = detailPlacement(head, right, width, nameIndent)
|
|
868
986
|
const lines = withDetail(head, laneDetail(view, open, reason, room), right, width, nameIndent)
|
|
869
|
-
return open ? lines : [...lines, ...foldedTaskRow(view, nameIndent, width, taskStyle)]
|
|
987
|
+
return open ? lines : [...lines, ...foldedTaskRow(view, nameIndent, { width, style: taskStyle, state })]
|
|
870
988
|
}
|
|
871
989
|
|
|
872
990
|
/** The running phase the lane line names, with its step and age; undefined while it names none or that one does not run. */
|
|
@@ -898,8 +1016,15 @@ const idleLabel = (view: LaneView): string => {
|
|
|
898
1016
|
const sessionLine = (view: LaneView, width: number): string => {
|
|
899
1017
|
const words = [modelLabel(view), idleLabel(view)].filter(Boolean).map((word) => paint.dim(word))
|
|
900
1018
|
const left = [gitLabel(view.status), ...words].join(paint.dim(PLACE_SEPARATOR))
|
|
901
|
-
const
|
|
902
|
-
|
|
1019
|
+
const right = [lanePortBadge(view), contextValue(view.context)].filter(Boolean).join(' ')
|
|
1020
|
+
const reserved = right ? Math.max(visibleLength(right) + 1, CONTEXT_CELL_WIDTH) : 0
|
|
1021
|
+
return spread(fit(left, Math.max(width - reserved, 1)), right, width)
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** The session line of an open lane, with a click target over its port badge. */
|
|
1025
|
+
const sessionFrame = (view: LaneView, width: number): Frame => {
|
|
1026
|
+
const line = sessionLine(view, width)
|
|
1027
|
+
return { ...textFrame([line]), targets: portTargets(view, line) }
|
|
903
1028
|
}
|
|
904
1029
|
|
|
905
1030
|
/** How many of `cells` fit on one line of `width` cells with the `+<n>` for the rest; at least one, which is then cut. */
|
|
@@ -1013,11 +1138,11 @@ const showsLaneActivity = ({ activity, status }: LaneView): boolean =>
|
|
|
1013
1138
|
/** True when a task line of the lane shimmers: the pane shimmers its tasks and the lane reports one to show. */
|
|
1014
1139
|
const showsShimmeringTask = (
|
|
1015
1140
|
view: LaneView,
|
|
1016
|
-
{ width, taskStyle }: Pick<LanesContext, 'width' | 'taskStyle'>,
|
|
1017
|
-
): boolean => taskStyle === SHIMMERING_TASK && foldedTaskRow(view, '', width, taskStyle).length > 0
|
|
1141
|
+
{ width, taskStyle, state }: Pick<LanesContext, 'width' | 'taskStyle' | 'state'>,
|
|
1142
|
+
): boolean => taskStyle === SHIMMERING_TASK && foldedTaskRow(view, '', { width, style: taskStyle, state }).length > 0
|
|
1018
1143
|
|
|
1019
1144
|
/** True when no line of the lane shimmers yet, so its first subagent line does: neither its activity nor its task. */
|
|
1020
|
-
const subagentLeads = (view: LaneView, context: Pick<LanesContext, 'width' | 'taskStyle'>): boolean =>
|
|
1145
|
+
const subagentLeads = (view: LaneView, context: Pick<LanesContext, 'width' | 'taskStyle' | 'state'>): boolean =>
|
|
1021
1146
|
!showsLaneActivity(view) && !showsShimmeringTask(view, context)
|
|
1022
1147
|
|
|
1023
1148
|
/**
|
|
@@ -1043,14 +1168,14 @@ const laneBlock = (
|
|
|
1043
1168
|
taskStyle,
|
|
1044
1169
|
}
|
|
1045
1170
|
const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
|
|
1046
|
-
const lines = laneLines(view, open, { width, taskStyle }, columns, reason)
|
|
1171
|
+
const lines = laneLines(view, open, { width, taskStyle, state }, columns, reason)
|
|
1047
1172
|
const lane = rowFrame(laneKey(view.progress.lane.name), lines, [], context)
|
|
1048
|
-
const leadsSubagent = subagentLeads(view, { width, taskStyle: lanesTaskStyle })
|
|
1173
|
+
const leadsSubagent = subagentLeads(view, { width, taskStyle: lanesTaskStyle, state })
|
|
1049
1174
|
const row = joinFrames([lane, ...activityFrames(view, { state, width, activityStyle, leadsSubagent })])
|
|
1050
1175
|
if (!open) return row
|
|
1051
1176
|
const inner = narrower(context, LANE_HEAD_CELLS)
|
|
1052
1177
|
const head = {
|
|
1053
|
-
session:
|
|
1178
|
+
session: sessionFrame(view, inner.width),
|
|
1054
1179
|
chips: chipLine(chipCells(laneChips(view), inner.tickets, view.sentFiles), inner.width),
|
|
1055
1180
|
pullRequests: pullRequestsFrame(view, inner),
|
|
1056
1181
|
}
|
|
@@ -1479,17 +1604,25 @@ const reasonInputOf = ({ progress, status, restNote }: LaneView): ReasonInput =>
|
|
|
1479
1604
|
restNote,
|
|
1480
1605
|
})
|
|
1481
1606
|
|
|
1482
|
-
/** `1
|
|
1607
|
+
/** `1 needs you · 3 idle`: what needs you in the tone of what waits, the idle count dim; a part at 0 left out. */
|
|
1483
1608
|
const lanesBalanceText = (lanes: readonly LaneView[]): string => {
|
|
1484
1609
|
const { waitsOnYou, idle } = lanesBalance(lanes.map(reasonInputOf))
|
|
1485
1610
|
return [
|
|
1486
|
-
waitsOnYou > 0 ? paintOfRole(TONE_ROLE.waiting)(TEXTS.
|
|
1611
|
+
waitsOnYou > 0 ? paintOfRole(TONE_ROLE.waiting)(TEXTS.lanesNeedYou(waitsOnYou)) : '',
|
|
1487
1612
|
idle > 0 ? paint.dim(TEXTS.lanesIdle(idle)) : '',
|
|
1488
1613
|
]
|
|
1489
1614
|
.filter(Boolean)
|
|
1490
1615
|
.join(paint.dim(` ${ITEM_SEPARATOR} `))
|
|
1491
1616
|
}
|
|
1492
1617
|
|
|
1618
|
+
/** The keys of the folded lanes that lay a step row below their line once they have the focus. */
|
|
1619
|
+
const stepRowKeysOf = (lanes: readonly LaneView[], { state, width, taskStyle }: LanesContext): string[] =>
|
|
1620
|
+
lanes.flatMap((view) => {
|
|
1621
|
+
const { name } = view.progress.lane
|
|
1622
|
+
const hidden = !isLaneOpen(state, name, view.status.agent) && namesPlan(view)
|
|
1623
|
+
return hidden && taskLineOf(view, '', { width, style: taskStyle }).length > 0 ? [laneKey(name)] : []
|
|
1624
|
+
})
|
|
1625
|
+
|
|
1493
1626
|
/**
|
|
1494
1627
|
* The lanes at work under the head `▾ Lanes · <n> <balance>`, open at the start; each lane indented under it, a
|
|
1495
1628
|
* blank line after an open lane, none above it, so its row keeps its screen line when it opens and folds under the
|
|
@@ -1617,6 +1750,7 @@ export const renderScreen = (
|
|
|
1617
1750
|
sourceBlock: stack.hits[sourcesSlot],
|
|
1618
1751
|
focusHits: state.showLegend ? focusHits.filter(({ line }) => line < plainHeader.length) : focusHits,
|
|
1619
1752
|
rowKeys: [...blockHits.map(({ key }) => key), ...targetKeys(body.targets)],
|
|
1753
|
+
stepRowKeys: stepRowKeysOf(active, { state, width, badgeTexts, taskStyle, activityStyle }),
|
|
1620
1754
|
header,
|
|
1621
1755
|
body: state.showLegend ? textFrame(legendLines(TEXTS.legend, width)) : body,
|
|
1622
1756
|
footer: [
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createConnection } from 'node:net'
|
|
2
|
+
|
|
3
|
+
/** How long a connection to the lane's port may take before the port counts as closed. */
|
|
4
|
+
const CONNECT_TIMEOUT_MS = 150
|
|
5
|
+
|
|
6
|
+
/** Whether something accepts a connection on `port` of this machine; a refusal, an error or the timeout answer false. */
|
|
7
|
+
export const isPortListening = (port: number, timeoutMs = CONNECT_TIMEOUT_MS): Promise<boolean> =>
|
|
8
|
+
new Promise((resolve) => {
|
|
9
|
+
const socket = createConnection({ port, host: '127.0.0.1' })
|
|
10
|
+
const settle = (listening: boolean) => {
|
|
11
|
+
socket.destroy()
|
|
12
|
+
resolve(listening)
|
|
13
|
+
}
|
|
14
|
+
socket.setTimeout(timeoutMs)
|
|
15
|
+
socket.once('connect', () => settle(true))
|
|
16
|
+
socket.once('timeout', () => settle(false))
|
|
17
|
+
socket.once('error', () => settle(false))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
/** The ports of `ports` something listens on, probed side by side; a lane without a port (null) is skipped. */
|
|
21
|
+
export const listeningPorts = async (ports: readonly (number | null)[]): Promise<Set<number>> => {
|
|
22
|
+
const unique = [...new Set(ports.filter((port): port is number => port !== null))]
|
|
23
|
+
const answers = await Promise.all(unique.map(async (port) => ((await isPortListening(port)) ? port : null)))
|
|
24
|
+
return new Set(answers.filter((port): port is number => port !== null))
|
|
25
|
+
}
|
package/src/plans/selection.ts
CHANGED
|
@@ -166,6 +166,17 @@ const ITEM_KEY = new RegExp(`^(${PHASE_KEY_SHAPE})#(\\d+)$`)
|
|
|
166
166
|
*/
|
|
167
167
|
export const itemKey = (phase: string, index: number): string => `${phase}#${index}`
|
|
168
168
|
|
|
169
|
+
const MORE_TASKS_SUFFIX = '>more'
|
|
170
|
+
|
|
171
|
+
/** The key of the second count row of a phase's checklist, which lists all its tasks like the first one (the phase key). */
|
|
172
|
+
export const moreTasksKey = (phase: string): string => `${phase}${MORE_TASKS_SUFFIX}`
|
|
173
|
+
|
|
174
|
+
/** The phase key a second count row's key belongs to, null for any other key. */
|
|
175
|
+
export const phaseOfMoreTasksKey = (key: string): string | null =>
|
|
176
|
+
key.endsWith(MORE_TASKS_SUFFIX) && isPhaseKey(key.slice(0, -MORE_TASKS_SUFFIX.length))
|
|
177
|
+
? key.slice(0, -MORE_TASKS_SUFFIX.length)
|
|
178
|
+
: null
|
|
179
|
+
|
|
169
180
|
const TASK_REST_PREFIX = 'rest>'
|
|
170
181
|
|
|
171
182
|
/** The key of the `▸ n more lines` row under an opened task, which opens the rest of its text. */
|
package/src/plans/snapshot.ts
CHANGED
|
@@ -62,6 +62,7 @@ import { readPhaseModels } from './phase-models-read'
|
|
|
62
62
|
import { parsedPlanPhases } from './plan-cache'
|
|
63
63
|
import { laneHeads, PULL_HEAD_NAMESPACE, planPullRequests, readerOncePerRef } from './plan-heads'
|
|
64
64
|
import { markStale, type PlannedPlan, plannedPlans } from './planned'
|
|
65
|
+
import { listeningPorts } from './port-listening'
|
|
65
66
|
import type { PullRequest } from './pull-requests'
|
|
66
67
|
import type { ReadCache } from './read-cache'
|
|
67
68
|
import { type SentChip, sentChipsByLane } from './sent-files'
|
|
@@ -95,6 +96,8 @@ export type Source = {
|
|
|
95
96
|
readSubagents?: (root: string, worktreeDirectory: string) => Map<string, LaneSubagents>
|
|
96
97
|
/** What agents report doing (Claude's task lists, `jh task`); defaults to reading `~/.claude/projects` and `.jackherd/tasks/`. */
|
|
97
98
|
readSignals?: (root: string, worktreeDirectory: string, now: number) => TaskSignal[]
|
|
99
|
+
/** Which of the lanes' ports a server listens on, probed once per snapshot; defaults to connecting to localhost. */
|
|
100
|
+
probePorts?: (ports: readonly (number | null)[]) => Promise<ReadonlySet<number>>
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
/**
|
|
@@ -194,6 +197,8 @@ type LaneSet = { lanes: SprintLane[]; unlisted: UnlistedLane[]; sources: LaneSou
|
|
|
194
197
|
|
|
195
198
|
/** What a lane's view is decorated with after it was read. */
|
|
196
199
|
type Decorations = {
|
|
200
|
+
/** The ports a server listens on; a lane whose port is among them shows its port badge. */
|
|
201
|
+
listening: ReadonlySet<number>
|
|
197
202
|
reading: SourcedWatchReading | null
|
|
198
203
|
phaseModels: readonly PhaseModelRecord[]
|
|
199
204
|
sprintDirectory: string
|
|
@@ -347,6 +352,9 @@ const laneView = async (
|
|
|
347
352
|
const laneSubagents = sources.subagents.get(lane.name)
|
|
348
353
|
const view: LaneView = {
|
|
349
354
|
progress,
|
|
355
|
+
planPhaseIds: Object.fromEntries(
|
|
356
|
+
Object.entries(phasesByHref).map(([href, phases]) => [href, phases.map(({ id }) => id)]),
|
|
357
|
+
),
|
|
350
358
|
...running,
|
|
351
359
|
chips: Object.fromEntries(
|
|
352
360
|
Object.entries(texts).map(([href, text]) => [href, extractLinkChips(text, settings.linkKinds)]),
|
|
@@ -488,6 +496,11 @@ const withSentFiles = (view: LaneView, chips: ReadonlyMap<string | null, SentChi
|
|
|
488
496
|
}
|
|
489
497
|
|
|
490
498
|
/** The view with what its resting lane waits on, read from the lane's report as the watcher reads it; as it was while the agent works. */
|
|
499
|
+
const withListening = (view: LaneView, listening: ReadonlySet<number>): LaneView => {
|
|
500
|
+
const { port } = view.progress.lane
|
|
501
|
+
return port !== null && listening.has(port) ? { ...view, listening: true } : view
|
|
502
|
+
}
|
|
503
|
+
|
|
491
504
|
const withRestNote = (view: LaneView, restNoteOf: Decorations['restNoteOf']): LaneView => {
|
|
492
505
|
const note = isRestingAgent(view.status.agent) ? restNoteOf(view.progress.lane.name) : null
|
|
493
506
|
return note === null ? view : { ...view, restNote: note }
|
|
@@ -619,19 +632,22 @@ const laneResultsOf = async (
|
|
|
619
632
|
/** Each lane's view with its tickets, the watcher's badges, its phases' models and the files sent from it. */
|
|
620
633
|
const decoratedViews = (
|
|
621
634
|
results: readonly LaneResult[],
|
|
622
|
-
{ reading, phaseModels, sprintDirectory, sentChips, restNoteOf }: Decorations,
|
|
635
|
+
{ listening, reading, phaseModels, sprintDirectory, sentChips, restNoteOf }: Decorations,
|
|
623
636
|
): LaneView[] =>
|
|
624
637
|
results.map((result) =>
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
638
|
+
withListening(
|
|
639
|
+
withRestNote(
|
|
640
|
+
withSentFiles(
|
|
641
|
+
withModels(
|
|
642
|
+
withBadges(withTickets(result, reading?.tickets ?? []), reading?.badges ?? {}),
|
|
643
|
+
phaseModels,
|
|
644
|
+
sprintDirectory,
|
|
645
|
+
),
|
|
646
|
+
sentChips,
|
|
631
647
|
),
|
|
632
|
-
|
|
648
|
+
restNoteOf,
|
|
633
649
|
),
|
|
634
|
-
|
|
650
|
+
listening,
|
|
635
651
|
),
|
|
636
652
|
)
|
|
637
653
|
|
|
@@ -749,7 +765,9 @@ export const loadSnapshot = async (
|
|
|
749
765
|
const unknownLaneTitle =
|
|
750
766
|
options.lane && results.length === 0 ? boardTexts(settings.board.term).laneNotListed(options.lane) : null
|
|
751
767
|
await gate.pass()
|
|
768
|
+
const listening = await (source.probePorts ?? listeningPorts)(results.map(({ view }) => view.progress.lane.port))
|
|
752
769
|
const views = decoratedViews(results, {
|
|
770
|
+
listening,
|
|
753
771
|
reading,
|
|
754
772
|
phaseModels: readPhaseModels(root),
|
|
755
773
|
sprintDirectory,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Tasks shown before the running one, and after it, in a window over a long list. */
|
|
2
|
+
export const CHECKLIST_BEFORE = 2
|
|
3
|
+
export const CHECKLIST_AFTER = 4
|
|
4
|
+
|
|
5
|
+
/** The least a task needs to be placed on the checklist. */
|
|
6
|
+
type Box = { done: boolean }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One row of a phase's checklist: a task by its index in the plan, or a count standing for the tasks the window
|
|
10
|
+
* leaves out above or below it.
|
|
11
|
+
*/
|
|
12
|
+
export type ChecklistRow = { kind: 'task'; index: number } | { kind: 'above' | 'below'; count: number }
|
|
13
|
+
|
|
14
|
+
const indexesOf = (start: number, end: number): number[] =>
|
|
15
|
+
Array.from({ length: Math.max(end - start, 0) }, (_, offset) => start + offset)
|
|
16
|
+
|
|
17
|
+
const taskRows = (indexes: readonly number[]): ChecklistRow[] => indexes.map((index) => ({ kind: 'task', index }))
|
|
18
|
+
|
|
19
|
+
/** The task the window is built around: the first one running, else the first open one, else the last one. */
|
|
20
|
+
const anchorOf = (items: readonly Box[], running: readonly number[]): number => {
|
|
21
|
+
if (running.length > 0) return Math.min(...running)
|
|
22
|
+
const firstOpen = items.findIndex(({ done }) => !done)
|
|
23
|
+
return firstOpen === -1 ? items.length - 1 : firstOpen
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The index of a phase's running task: the first one in `reported` that is still open, else, while the lane runs the
|
|
28
|
+
* phase (`laneRuns`), its first open task; null when nothing runs. The tasks agents report (a `jh task start` text a
|
|
29
|
+
* task names, a task list) win over the plan's order, since the agent knows where it is.
|
|
30
|
+
*/
|
|
31
|
+
export const runningTaskIndexes = (items: readonly Box[], reported: readonly number[], laneRuns: boolean): number[] => {
|
|
32
|
+
const open = reported.filter((index) => items[index]?.done === false)
|
|
33
|
+
if (open.length > 0) return [...new Set(open)]
|
|
34
|
+
const firstOpen = items.findIndex(({ done }) => !done)
|
|
35
|
+
return laneRuns && firstOpen !== -1 ? [firstOpen] : []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The rows of a phase's checklist, always in the plan's order: ticked tasks first, the running ones, the ones to come.
|
|
40
|
+
* A short list, or one `expanded` by a click, shows every task. A long one shows a window of `CHECKLIST_BEFORE` tasks
|
|
41
|
+
* before the first running one and `CHECKLIST_AFTER` after it, widened to hold every running task, with a count row
|
|
42
|
+
* above and below for what it leaves out, so the order is never broken.
|
|
43
|
+
*/
|
|
44
|
+
export const checklistRows = (items: readonly Box[], running: readonly number[], expanded: boolean): ChecklistRow[] => {
|
|
45
|
+
const all = indexesOf(0, items.length)
|
|
46
|
+
if (expanded || items.length <= CHECKLIST_BEFORE + 1 + CHECKLIST_AFTER) return taskRows(all)
|
|
47
|
+
const anchor = anchorOf(items, running)
|
|
48
|
+
const start = Math.max(anchor - CHECKLIST_BEFORE, 0)
|
|
49
|
+
const end = Math.min(Math.max(anchor + CHECKLIST_AFTER, ...running), items.length - 1) + 1
|
|
50
|
+
const aboveCount = start
|
|
51
|
+
const belowCount = items.length - end
|
|
52
|
+
return [
|
|
53
|
+
...(aboveCount > 0 ? [{ kind: 'above' as const, count: aboveCount }] : []),
|
|
54
|
+
...taskRows(indexesOf(start, end)),
|
|
55
|
+
...(belowCount > 0 ? [{ kind: 'below' as const, count: belowCount }] : []),
|
|
56
|
+
]
|
|
57
|
+
}
|