jackherd 1.18.1 → 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.
Files changed (78) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +31 -26
  4. package/assets/icons/model.svg +1 -0
  5. package/dist/{chunk-N5FAOQZL.js → chunk-JDO6O3UO.js} +52 -2
  6. package/dist/config.d.ts +15 -2
  7. package/dist/config.js +22 -3
  8. package/dist/plan-format.d.ts +84 -1
  9. package/dist/plan-format.js +85 -1
  10. package/docs/plan-contract.md +8 -2
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +1 -7
  14. package/kit/hooks/hooks/approval-gate/decision.ts +10 -11
  15. package/kit/hooks/hooks/approval-gate/run.ts +23 -3
  16. package/kit/hooks/hooks/browser-guard/decision.ts +7 -0
  17. package/kit/hooks/hooks/browser-guard/index.ts +50 -0
  18. package/kit/hooks/hooks/plain-question/arguments.ts +14 -1
  19. package/kit/hooks/hooks/plain-question/card.ts +102 -0
  20. package/kit/hooks/hooks/plain-question/form.ts +25 -9
  21. package/kit/hooks/hooks/plain-question/index.ts +20 -25
  22. package/kit/hooks/hooks/plain-question/recommendation.ts +6 -6
  23. package/kit/hooks/hooks/return-brief/decision.ts +29 -0
  24. package/kit/hooks/hooks/return-brief/index.ts +37 -0
  25. package/kit/hooks/hooks/return-brief/settings.ts +11 -0
  26. package/kit/hooks/hooks/shared/coordinator-call.ts +25 -0
  27. package/kit/hooks/hooks/shared/transcript.ts +9 -0
  28. package/kit/hooks/hooks.json +18 -1
  29. package/kit/review/agents/security-audit.md +1 -1
  30. package/kit/workflow/agents/browser-check.md +29 -0
  31. package/kit/workflow/skills/implement/SKILL.md +5 -3
  32. package/kit/workflow/skills/orchestrate/SKILL.md +36 -14
  33. package/kit/workflow/skills/orchestrate/references/acceptance.md +10 -9
  34. package/kit/workflow/skills/orchestrate/references/close-lane.md +1 -1
  35. package/kit/workflow/skills/orchestrate/references/coordinator-clear.md +45 -0
  36. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +15 -12
  37. package/kit/workflow/skills/plan/SKILL.md +2 -0
  38. package/kit/workflow/skills/plan/references/template-English.md +8 -0
  39. package/kit/workflow/skills/plan/references/template-German.md +8 -0
  40. package/package.json +1 -1
  41. package/schemas/jackherd.schema.json +16 -0
  42. package/src/brief/command.ts +45 -0
  43. package/src/brief/lane-brief.ts +51 -0
  44. package/src/brief/lane-facts.ts +64 -0
  45. package/src/check/skill-budget-defaults.ts +15 -4
  46. package/src/cli.ts +1 -0
  47. package/src/config/schema.ts +22 -0
  48. package/src/lane/command.ts +1 -2
  49. package/src/lane-prompt/texts.ts +2 -2
  50. package/src/plan-contract/command.ts +1 -0
  51. package/src/plan-contract/contract.ts +28 -5
  52. package/src/plan-format/index.ts +1 -0
  53. package/src/plan-format/plan-head.ts +131 -0
  54. package/src/plans/active-tasks.ts +8 -1
  55. package/src/plans/click-hint.ts +2 -1
  56. package/src/plans/focus-overlay.ts +5 -2
  57. package/src/plans/folded-detail.ts +47 -0
  58. package/src/plans/lane-line.ts +86 -0
  59. package/src/plans/lane-reason.ts +24 -4
  60. package/src/plans/layout-phase.ts +4 -20
  61. package/src/plans/layout-summary.ts +56 -45
  62. package/src/plans/layout.ts +178 -44
  63. package/src/plans/port-listening.ts +25 -0
  64. package/src/plans/selection.ts +11 -0
  65. package/src/plans/snapshot.ts +27 -9
  66. package/src/plans/task-checklist.ts +57 -0
  67. package/src/plans/texts.ts +34 -9
  68. package/src/resume/command.ts +40 -6
  69. package/src/resume/coordinator-read.ts +136 -0
  70. package/src/resume/state.ts +163 -0
  71. package/src/resume/texts.ts +6 -0
  72. package/src/sync/render.ts +5 -1
  73. package/src/vocabulary/index.ts +74 -0
  74. package/src/watch/clear-moment.ts +228 -0
  75. package/src/watch/command.ts +92 -8
  76. package/src/watch/lane-report.ts +5 -1
  77. package/src/watch/reference-paths.ts +8 -4
  78. package/src/watch/watch-events.ts +13 -0
@@ -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
- /** A folded lane's marks: its pull request badges, and the ticket mark while a ticket is new. */
710
- const laneMarks = ({ pullRequests, badges, tickets }: LaneView): string =>
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
- * `P3 Search API +1 · 12/20` for a folded lane: the phase it names, `+n` for the other active phases, the phase's
715
- * ticked boxes. An open lane names it on a phase branch, so its own line does not.
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 foldedPhaseLabel = (view: LaneView): string => {
718
- const phase = headPhase(view)
719
- if (!phase) return ''
720
- const more = summaryOf(view).active.filter((active) => active.phase.id !== phase.id).length
721
- return `${nextPhaseLabel(view)}${more > 0 ? ` +${more}` : ''} · ${phase.done}/${phase.total}`
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
- /** `P4 12/18`: the phase a resting lane names and its ticked boxes, without the title the reason takes the room of. */
739
- const restingPhaseLabel = (view: LaneView): string => {
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
- return phase ? paint.dim(`${phaseLabel(phase.id)} ${phase.done}/${phase.total}`) : ''
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, the reason a resting lane rests, and, folded only, the phase it
746
- * works on (a resting one by its label and count); the branch is on the session line. Wider than `room`, the reason
747
- * is cut and the phase stays whole behind it.
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 phase = restsFolded(view, open) ? restingPhaseLabel(view) : open ? '' : foldedPhaseLabel(view)
753
- const lead = [agentLabel(view.status.agent), marker, reasonText(reason)].filter(Boolean).join(' ')
754
- if (!reason || !phase) return [lead, phase].filter(Boolean).join(' ')
755
- const leadRoom = Math.max(room - COLUMN_SEPARATOR.length - visibleLength(phase), 1)
756
- return `${visibleLength(lead) > leadRoom ? fit(lead, leadRoom) : lead}${COLUMN_SEPARATOR}${phase}`
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
- /** The task row below a folded lane's phase row, under the lane's name; none while the lane reports no task. */
841
- const foldedTaskRow = (view: LaneView, indent: string, width: number, style: TaskStyle): string[] => {
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 right = open || detailFitsBeside(head, aligned, width, nameIndent) ? aligned : markedRightTight(view, columns)
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 context = contextValue(view.context)
902
- return spread(fit(left, Math.max(width - (context ? CONTEXT_CELL_WIDTH : 0), 1)), context, width)
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: textFrame([sessionLine(view, inner.width)]),
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 waits on you · 3 idle`: what waits on you in the tone of what waits, the idle count dim; a part at 0 left out. */
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.lanesWaitOnYou(waitsOnYou)) : '',
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
+ }
@@ -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. */
@@ -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
- withRestNote(
626
- withSentFiles(
627
- withModels(
628
- withBadges(withTickets(result, reading?.tickets ?? []), reading?.badges ?? {}),
629
- phaseModels,
630
- sprintDirectory,
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
- sentChips,
648
+ restNoteOf,
633
649
  ),
634
- restNoteOf,
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
+ }