jackherd 1.6.1 → 1.7.1

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 (156) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +228 -312
  4. package/assets/branding/README.md +7 -3
  5. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  6. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  7. package/assets/branding/jackherd-logo-dark.svg +1 -1
  8. package/assets/icons/bark-dark.svg +1 -0
  9. package/assets/icons/bark.svg +1 -0
  10. package/assets/icons/bowl-dark.svg +1 -0
  11. package/assets/icons/bowl.svg +1 -0
  12. package/assets/icons/decide-dark.svg +1 -0
  13. package/assets/icons/decide.svg +1 -0
  14. package/assets/icons/ear-dark.svg +1 -0
  15. package/assets/icons/ear.svg +1 -0
  16. package/assets/icons/lanes-dark.svg +1 -0
  17. package/assets/icons/lanes.svg +1 -0
  18. package/assets/icons/plan-dark.svg +1 -0
  19. package/assets/icons/plan.svg +1 -0
  20. package/assets/icons/space.svg +1 -0
  21. package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
  22. package/dist/config.d.ts +14 -3
  23. package/dist/config.js +111 -10
  24. package/dist/plan-format.d.ts +180 -77
  25. package/dist/plan-format.js +292 -93
  26. package/docs/lanes-dark.svg +50 -0
  27. package/docs/lanes.svg +50 -1
  28. package/docs/skills-dark.svg +65 -0
  29. package/docs/skills.svg +65 -1
  30. package/docs/workflow-dark.svg +138 -0
  31. package/docs/workflow.svg +135 -169
  32. package/herdr-plugin/herdr-plugin.toml +6 -6
  33. package/herdr-plugin/open-plans.ts +1 -1
  34. package/herdr-plugin/restore-plans.ts +7 -7
  35. package/install.sh +1 -1
  36. package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
  37. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
  38. package/kit/hooks/hooks/approval-gate/index.ts +69 -0
  39. package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
  40. package/kit/hooks/hooks/approval-gate/request.ts +111 -0
  41. package/kit/hooks/hooks/approval-gate/run.ts +94 -0
  42. package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
  43. package/kit/hooks/hooks/approval-gate/store.ts +70 -0
  44. package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
  45. package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
  46. package/kit/hooks/hooks/merge-guard/run.ts +2 -1
  47. package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
  48. package/kit/hooks/hooks/model-gate/index.ts +58 -0
  49. package/kit/hooks/hooks/model-gate/run.ts +63 -0
  50. package/kit/hooks/hooks/model-log/index.ts +60 -0
  51. package/kit/hooks/hooks/model-log/run.ts +57 -0
  52. package/kit/hooks/hooks/model-log/store.ts +40 -0
  53. package/kit/hooks/hooks/model-log/transcript.ts +37 -0
  54. package/kit/hooks/hooks/shared/checkout.ts +22 -0
  55. package/kit/hooks/hooks/shared/command.ts +6 -0
  56. package/kit/hooks/hooks/shared/config.ts +21 -0
  57. package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
  58. package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
  59. package/kit/hooks/hooks/shared/files.ts +19 -0
  60. package/kit/hooks/hooks/shared/fresh.ts +4 -0
  61. package/kit/hooks/hooks/shared/json.ts +4 -0
  62. package/kit/hooks/hooks/shared/model-family.ts +48 -0
  63. package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
  64. package/kit/hooks/hooks/shared/private-directory.ts +29 -0
  65. package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
  66. package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
  67. package/kit/hooks/hooks.json +25 -0
  68. package/kit/workflow/skills/follow/SKILL.md +4 -3
  69. package/kit/workflow/skills/implement/SKILL.md +6 -6
  70. package/kit/workflow/skills/orchestrate/SKILL.md +109 -27
  71. package/kit/workflow/skills/plan/SKILL.md +5 -5
  72. package/kit/workflow/skills/start/SKILL.md +2 -2
  73. package/package.json +4 -1
  74. package/schemas/jackherd.schema.json +43 -5
  75. package/src/check/command.ts +31 -4
  76. package/src/check/deferral-arrows.ts +1 -1
  77. package/src/check/parallel-report.ts +23 -0
  78. package/src/check/plan-structure-check.ts +120 -53
  79. package/src/cli.ts +8 -2
  80. package/src/config/legacy-names.ts +8 -0
  81. package/src/config/nested-quantifier.ts +73 -0
  82. package/src/config/repository-directory.ts +11 -0
  83. package/src/config/schema.ts +63 -8
  84. package/src/plan-format/board-prose.ts +36 -0
  85. package/src/plan-format/dependencies.ts +227 -0
  86. package/src/plan-format/documentation-gate.ts +16 -0
  87. package/src/plan-format/index.ts +23 -1
  88. package/src/plan-format/lanes.ts +35 -1
  89. package/src/plan-format/model-column.ts +52 -0
  90. package/src/plan-format/phases.ts +16 -1
  91. package/src/plan-format/plan-structure.ts +10 -28
  92. package/src/plan-format/progress.ts +7 -29
  93. package/src/plans/command.ts +3 -3
  94. package/src/plans/current-phase.ts +71 -0
  95. package/src/plans/ensure-panes.ts +2 -2
  96. package/src/plans/herd-command.ts +4 -0
  97. package/src/plans/lane-discovery.ts +14 -2
  98. package/src/plans/lane-tickets.ts +69 -0
  99. package/src/plans/layout.ts +182 -88
  100. package/src/plans/live.ts +5 -1
  101. package/src/plans/phase-models-read.ts +37 -0
  102. package/src/plans/phase-models.ts +70 -0
  103. package/src/plans/plan-panes.ts +8 -7
  104. package/src/plans/pull-requests.ts +78 -4
  105. package/src/plans/selection.ts +1 -40
  106. package/src/plans/settings.ts +9 -3
  107. package/src/plans/shown-with-more.ts +5 -0
  108. package/src/plans/snapshot.ts +82 -8
  109. package/src/plans/sprint-html.ts +2 -2
  110. package/src/plans/supervisor.ts +5 -4
  111. package/src/plans/symbols.ts +9 -6
  112. package/src/plans/texts.ts +30 -13
  113. package/src/plans/view.ts +1 -0
  114. package/src/sync/command.ts +16 -1
  115. package/src/sync/herdr-plugin.ts +1 -1
  116. package/src/sync/next-steps.ts +1 -1
  117. package/src/sync/render.ts +2 -1
  118. package/src/sync/setup-questions.ts +2 -2
  119. package/src/terminal/text.ts +4 -0
  120. package/src/terminal/types.ts +1 -1
  121. package/src/update/command.ts +4 -2
  122. package/src/update/update.ts +33 -9
  123. package/src/vocabulary/index.ts +39 -0
  124. package/src/watch/approvals.ts +96 -0
  125. package/src/watch/command.ts +154 -50
  126. package/src/watch/failed-tests.ts +52 -0
  127. package/src/watch/failure-read.ts +56 -0
  128. package/src/watch/failures.ts +107 -0
  129. package/src/watch/feedback-item.ts +21 -0
  130. package/src/watch/feedback-read.ts +2 -1
  131. package/src/watch/feedback.ts +37 -28
  132. package/src/watch/gh-process.ts +53 -0
  133. package/src/watch/gh.ts +10 -20
  134. package/src/watch/heartbeat-progress.ts +1 -1
  135. package/src/watch/lane-model.ts +54 -0
  136. package/src/watch/lane-parallel.ts +46 -0
  137. package/src/watch/lane-progress.ts +28 -4
  138. package/src/watch/lane-report.ts +62 -9
  139. package/src/watch/lane-sources.ts +1 -1
  140. package/src/watch/no-finding.ts +47 -0
  141. package/src/watch/plan-badges-read.ts +19 -7
  142. package/src/watch/plan-badges.ts +59 -7
  143. package/src/watch/plan-views.ts +5 -5
  144. package/src/watch/previews.ts +3 -9
  145. package/src/watch/pull-requests.ts +12 -2
  146. package/src/watch/recorded-reads.ts +25 -0
  147. package/src/watch/source-fields.ts +4 -0
  148. package/src/watch/source-tickets.ts +99 -0
  149. package/src/watch/sources.ts +62 -15
  150. package/src/watch/texts.ts +21 -1
  151. package/src/watch/watch-defaults.ts +8 -0
  152. package/src/watch/watch-events.ts +64 -4
  153. package/src/watch/watcher-lock.ts +1 -1
  154. package/src/watch/workflow-runs.ts +5 -7
  155. package/docs/lanes.mmd +0 -9
  156. package/docs/skills.mmd +0 -10
@@ -1,12 +1,13 @@
1
1
  import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
- import { laneNameOf, parseSprintLanes, phaseIdsOfCell } from './lanes'
3
- import { escapeRegExp, firstTableRows, sectionLines, sectionStart } from './markdown'
2
+ import { changedOpenLaneRows, type OpenLaneRow, phaseIdsOfCell } from './lanes'
3
+ import { escapeRegExp, sectionLines } from './markdown'
4
4
  import {
5
5
  deferralTarget,
6
6
  endsPhase,
7
7
  isCheckboxLine,
8
8
  isOpenCheckboxLine,
9
9
  isReviewGateLine,
10
+ type PhaseHeading,
10
11
  type PlanPhase,
11
12
  parsePlanPhases,
12
13
  phaseHeadingPattern,
@@ -14,10 +15,10 @@ import {
14
15
  import { terminalSafe } from './terminal-text'
15
16
 
16
17
  /** A `### Phase` heading (1-based line) whose section has tasks and neither a review gate nor the line saying why not. */
17
- export type PhaseWithoutGate = { line: number; id: string; title: string }
18
+ export type PhaseWithoutGate = PhaseHeading
18
19
 
19
20
  /** A row of the lanes table (1-based line) whose phases cell names no phase id. */
20
- export type RowWithoutPhase = { line: number; lane: string; phases: string }
21
+ export type RowWithoutPhase = OpenLaneRow
21
22
 
22
23
  /**
23
24
  * A `### Phase` heading (1-based line) whose tasks are all ticked on the base branch while its own section still
@@ -118,33 +119,14 @@ export const mergedPhasesWithOpenBoxes = (
118
119
  }
119
120
 
120
121
  /**
121
- * The rows of the lanes table (`## Spuren`) that are not in `previousLines` word for word and whose phases cell
122
- * names no phase id (`P10c`), so neither the view nor the watcher can tie that step to a phase. The table is read
123
- * as `parseSprintLanes` reads it (its first table, a lane closed when any of its rows says so); rows of a closed
124
- * lane are history and stay out.
122
+ * The changed rows of open lanes whose phases cell names no phase id (`P10c`), so neither the view nor the watcher
123
+ * can tie that step to a phase.
125
124
  */
126
125
  export const boardRowsWithoutPhase = (
127
126
  markdown: string,
128
127
  language: PlanLanguage,
129
128
  previousLines: ReadonlySet<string>,
130
- ): RowWithoutPhase[] => {
131
- const start = sectionStart(markdown, PLAN_VOCABULARY[language].sections.lanes)
132
- if (start === -1) return []
133
- const closed = new Set(
134
- parseSprintLanes(markdown, language)
135
- .filter((lane) => lane.closed)
136
- .map((lane) => lane.name),
129
+ ): RowWithoutPhase[] =>
130
+ changedOpenLaneRows(markdown, language, previousLines).filter(
131
+ ({ phases }) => phaseIdsOfCell(phases, language).length === 0,
137
132
  )
138
- const rows: RowWithoutPhase[] = []
139
- let previous: string | null = null
140
- for (const { index, line, cells } of firstTableRows(
141
- sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes),
142
- )) {
143
- const [name = '', , , phases = ''] = cells
144
- const lane = laneNameOf(name, previous)
145
- previous = lane ?? previous
146
- const counts = lane !== null && !closed.has(lane) && !previousLines.has(line)
147
- if (counts && phaseIdsOfCell(phases, language).length === 0) rows.push({ line: start + index + 1, lane, phases })
148
- }
149
- return rows
150
- }
@@ -33,30 +33,12 @@ export const sumPhases = (phases: readonly PhaseProgress[]): { done: number; tot
33
33
  total: phases.reduce((sum, phase) => sum + phase.total, 0),
34
34
  })
35
35
 
36
- const baseId = (id: string): string => id.replace(/[a-z]$/, '')
37
-
38
- const findPhase = (phases: PlanPhase[], id: string): PlanPhase | undefined =>
39
- phases.find((phase) => phase.id === id) ?? phases.find((phase) => phase.id === baseId(id))
40
-
41
- /**
42
- * The ids of a step that show a phase of their own. An id the plan lacks (`5b`) falls back to its
43
- * base phase (`5`); when the step already names that phase by its own id, or an earlier id fell
44
- * back to it, the fallback would list the same tasks twice, so it is dropped. A lone fallback
45
- * stays: the sprint line `P4a` finds phase 4 in the plans that have no `4a`.
46
- */
47
- export const keptPhaseIds = (phases: PlanPhase[], ids: readonly string[]): string[] => {
48
- const shown = new Set(ids.flatMap((id) => phases.filter((phase) => phase.id === id)))
49
- return ids.filter((id) => {
50
- const phase = findPhase(phases, id)
51
- if (!phase || phase.id === id) return true
52
- if (shown.has(phase)) return false
53
- shown.add(phase)
54
- return true
55
- })
56
- }
36
+ /** The phase with exactly this id: a board row's `3c` never falls back to phase 3. */
37
+ const phaseById = <T extends { id: string }>(phases: readonly T[], id: string): T | undefined =>
38
+ phases.find((phase) => phase.id === id)
57
39
 
58
40
  const phaseProgress = (phases: PlanPhase[], id: string): PhaseProgress => {
59
- const phase = findPhase(phases, id)
41
+ const phase = phaseById(phases, id)
60
42
  const items = phase?.items ?? []
61
43
  return {
62
44
  id,
@@ -102,16 +84,12 @@ const newestStepWith = (
102
84
 
103
85
  type LocatedPhase = { phase: PhaseProgress; current: NonNullable<LaneProgress['current']> }
104
86
 
105
- /** The phase a step shows for `id`: by that id, else by its base id, as `keptPhaseIds` keeps a lone fallback. */
106
- const shownPhase = (phases: readonly PhaseProgress[], id: string): PhaseProgress | undefined =>
107
- phases.find((phase) => phase.id === id) ?? phases.find((phase) => phase.id === baseId(id))
108
-
109
87
  /** The phase `id` as the newest step before `before` that shows it from the same plan has it. */
110
88
  const earlierPhase = (steps: readonly StepProgress[], before: number, id: string): LocatedPhase | null => {
111
89
  const planHref = steps[before].step.planHref
112
90
  for (let stepIndex = before - 1; stepIndex >= 0; stepIndex--) {
113
91
  if (steps[stepIndex].step.planHref !== planHref) continue
114
- const phase = shownPhase(steps[stepIndex].phases, id)
92
+ const phase = phaseById(steps[stepIndex].phases, id)
115
93
  if (phase) return { phase, current: { stepIndex, phaseId: phase.id } }
116
94
  }
117
95
  return null
@@ -179,7 +157,7 @@ export const currentPhaseOf = (steps: readonly StepProgress[]): LaneProgress['cu
179
157
  export const laneProgress = (lane: SprintLane, plans: PlansByHref): LaneProgress => {
180
158
  const steps = lane.steps.map((step) => {
181
159
  const phases = step.planHref ? plans[step.planHref] : undefined
182
- return { step, phases: phases ? keptPhaseIds(phases, step.phaseIds).map((id) => phaseProgress(phases, id)) : [] }
160
+ return { step, phases: phases ? step.phaseIds.map((id) => phaseProgress(phases, id)) : [] }
183
161
  })
184
162
  return { lane, steps, current: currentPhaseOf(steps), ...sumPhases(steps.flatMap((step) => step.phases)) }
185
163
  }
@@ -187,7 +165,7 @@ export const laneProgress = (lane: SprintLane, plans: PlansByHref): LaneProgress
187
165
  export const currentPhase = (progress: LaneProgress): PhaseProgress | null => {
188
166
  if (!progress.current) return null
189
167
  const { stepIndex, phaseId } = progress.current
190
- return progress.steps[stepIndex].phases.find((phase) => phase.id === phaseId) ?? null
168
+ return phaseById(progress.steps[stepIndex].phases, phaseId) ?? null
191
169
  }
192
170
 
193
171
  /** The item being worked on: the first open checkbox of a phase in plan order, a review-gate item included; null when none is open. */
@@ -27,8 +27,8 @@ const printOnce = async (source: Source): Promise<void> => {
27
27
  }
28
28
 
29
29
  /**
30
- * `jh plans [<board.md>] [--lane <name>] [--once | --ensure]`: a live view of a board's lanes and their plan
31
- * progress; `--ensure` instead brings back the missing views beside the coordinator and each lane.
30
+ * `jh herd [<board.md>] [--lane <name>] [--once | --ensure]`: a live view of a board's lanes and their plan
31
+ * progress; `--ensure` instead brings back the missing herd panes beside the coordinator and each lane.
32
32
  * `home` is where the global config is looked up when the project has none.
33
33
  */
34
34
  export const run = async (args: string[], home: string = homedir()): Promise<number> => {
@@ -45,7 +45,7 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
45
45
  settings.adapter === TERMINAL_ADAPTER.herdr
46
46
  ? ensurePlanPanes(root, settings, picked.file)
47
47
  : ['the terminal adapter is not herdr: no panes to bring back']
48
- for (const line of lines.length > 0 ? lines : ['every plans view is running']) console.log(line)
48
+ for (const line of lines.length > 0 ? lines : ['every herd pane is running']) console.log(line)
49
49
  return 0
50
50
  }
51
51
  const source = { root, sprintFile: picked.file, options, settings, terminal, boardNotice: picked.notice }
@@ -0,0 +1,71 @@
1
+ import { isTickedUnmerged, type LaneProgress, type PhaseProgress, type PlansByHref } from '../plan-format'
2
+
3
+ /** A phase of a plan, as the sprint table spells the plan's link. */
4
+ export type PhaseRef = { planHref: string; phaseId: string }
5
+
6
+ /** Per plan link and phase id, the state of its checkboxes as `1` (ticked) and `0` (open) in plan order. */
7
+ export type TickSnapshot = Record<string, Record<string, string>>
8
+
9
+ /**
10
+ * What the herd remembers of a lane between two reads: the ticks it saw, the phase whose ticks moved last and the
11
+ * identity of the newest board row (`newestRowIdentity`), which a new row changes.
12
+ */
13
+ export type LaneMemory = { ticks: TickSnapshot; moved: PhaseRef | null; row: string }
14
+
15
+ export const tickSnapshotOf = (plans: PlansByHref): TickSnapshot =>
16
+ Object.fromEntries(
17
+ Object.entries(plans).map(([href, phases]) => [
18
+ href,
19
+ Object.fromEntries(
20
+ phases.map((phase) => [phase.id, phase.items.map((item) => (item.done ? '1' : '0')).join('')]),
21
+ ),
22
+ ]),
23
+ )
24
+
25
+ /**
26
+ * The phases both reads have whose ticks differ, in plan order; a plan or phase new in `next` has no state to move
27
+ * from. A task added to or removed from a phase changes its tick string and counts as movement.
28
+ */
29
+ export const movedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
30
+ Object.entries(next).flatMap(([planHref, phases]) =>
31
+ Object.entries(phases).flatMap(([phaseId, ticks]) => {
32
+ const before = previous[planHref]?.[phaseId]
33
+ return before !== undefined && before !== ticks ? [{ planHref, phaseId }] : []
34
+ }),
35
+ )
36
+
37
+ /** The newest board row of a lane as its step index and the phase ids it shows, a step `withWorkedPhases` added not counted. */
38
+ export const newestRowIdentity = ({ steps }: LaneProgress): string => {
39
+ const index = steps.reduce((last, { step }, stepIndex) => (step.withoutRow ? last : stepIndex), -1)
40
+ return `${index}:${(steps[index]?.phases ?? []).map((phase) => phase.id).join(',')}`
41
+ }
42
+
43
+ /**
44
+ * The memory after a read of the lane's plans. The first read has no earlier ticks and no moved phase; exactly one
45
+ * moved phase becomes the remembered one; none keeps the one moved last; several at once decide nothing, so the
46
+ * newest board row rules again until a single phase moves. A changed newest board row (`row`) is an explicit
47
+ * signal and clears the memory.
48
+ */
49
+ export const nextMemory = (previous: LaneMemory | undefined, plans: PlansByHref, row: string): LaneMemory => {
50
+ const ticks = tickSnapshotOf(plans)
51
+ if (!previous || previous.row !== row) return { ticks, moved: null, row }
52
+ const moved = movedPhases(previous.ticks, ticks)
53
+ if (moved.length === 0) return { ticks, moved: previous.moved, row }
54
+ return { ticks, moved: moved.length === 1 ? moved[0] : null, row }
55
+ }
56
+
57
+ /**
58
+ * The progress with `moved` as the current phase, in the newest step that shows it with checkboxes; unchanged
59
+ * when there is no moved phase, the lane does not show it or it is done and merged, so the newest board row decides
60
+ * as before.
61
+ */
62
+ export const withMovedPhase = (progress: LaneProgress, moved: PhaseRef | null): LaneProgress => {
63
+ if (!moved) return progress
64
+ const isCurrentWork = (phase: PhaseProgress): boolean =>
65
+ phase.id === moved.phaseId && phase.total > 0 && (phase.done < phase.total || isTickedUnmerged(phase))
66
+ const showsIt = progress.steps.map(
67
+ ({ step, phases }) => step.planHref === moved.planHref && phases.some(isCurrentWork),
68
+ )
69
+ const stepIndex = showsIt.lastIndexOf(true)
70
+ return stepIndex < 0 ? progress : { ...progress, current: { stepIndex, phaseId: moved.phaseId } }
71
+ }
@@ -40,14 +40,14 @@ const apply = (action: PaneAction): string => {
40
40
  ? splitPaneOf(runHerdr(['pane', 'split', action.pane, '--direction', 'right', '--no-focus']))
41
41
  : action.pane
42
42
  runHerdr(['pane', 'run', target, action.command])
43
- return `${action.kind === 'split' ? `split ${action.pane}, ` : ''}started the plans view in ${target}`
43
+ return `${action.kind === 'split' ? `split ${action.pane}, ` : ''}started the herd in ${target}`
44
44
  } catch (error) {
45
45
  return `${action.pane}: ${errorText(error)}`
46
46
  }
47
47
  }
48
48
 
49
49
  /**
50
- * `plans --ensure`: brings back every missing plans view beside the coordinator and each lane of the board, and
50
+ * `herd --ensure`: brings back every missing herd pane beside the coordinator and each lane of the board, and
51
51
  * leaves running ones alone, so it can run after every start. A lane gets its view once its agent runs. One line per
52
52
  * action or skipped target, none when every view runs.
53
53
  */
@@ -0,0 +1,4 @@
1
+ /** The CLI word of the herd and the alias it had up to 1.6, which running panes of that version still carry. */
2
+ export const HERD_COMMAND = { name: 'herd', alias: 'plans' } as const
3
+
4
+ export const HERD_COMMAND_WORDS: readonly string[] = Object.values(HERD_COMMAND)
@@ -9,14 +9,26 @@ export type LaneSources = {
9
9
 
10
10
  export type LaneSetChange = { added: string[]; removed: string[] }
11
11
 
12
+ /**
13
+ * The helper agents of the lanes: an agent named `<lane>-<purpose>` that a lane started in a pane of its own, such as
14
+ * for a measurement. A name that is a worktree of its own is that lane, not a helper. The prefix only decides whether
15
+ * the agent is watched, not whose it is: under lanes `a` and `a-b` the agent `a-b-c` matches both and is watched once,
16
+ * and any agent named after a lane with a suffix is watched, whoever started it.
17
+ */
18
+ const helpersOf = (lanes: readonly string[], worktrees: readonly string[], agentNames: readonly string[]): string[] =>
19
+ [...new Set(agentNames)].filter(
20
+ (name) => !worktrees.includes(name) && lanes.some((lane) => name.startsWith(`${lane}-`)),
21
+ )
22
+
12
23
  /**
13
24
  * The lanes to watch when none are named: a worktree directory whose name a herdr agent carries or
14
25
  * the board's lanes table lists. A worktree with neither (a branch parked in its own checkout) is no
15
- * lane, and an agent or a row without a worktree is none either.
26
+ * lane, and an agent or a row without a worktree is none either, except a lane's helper agent (`helpersOf`).
16
27
  */
17
28
  export const discoverLanes = ({ worktrees, agentNames, sprintLanes }: LaneSources): string[] => {
18
29
  const claimed = new Set([...agentNames, ...sprintLanes])
19
- return [...new Set(worktrees)].filter((worktree) => claimed.has(worktree)).sort()
30
+ const lanes = [...new Set(worktrees)].filter((worktree) => claimed.has(worktree))
31
+ return [...lanes, ...helpersOf(lanes, worktrees, agentNames)].sort()
20
32
  }
21
33
 
22
34
  /** The lanes that joined and left between two discoveries, each sorted. */
@@ -0,0 +1,69 @@
1
+ import type { SprintLane } from '../plan-format'
2
+ import type { SourceTicket, TicketBadge } from '../watch/plan-badges'
3
+ import type { LinkChip } from './link-chips'
4
+ import { shownWithMore } from './shown-with-more'
5
+
6
+ /** The tickets of one lane as drawn: the first `SHOWN_TICKETS`, how many more, and how many of all are new. */
7
+ export type LaneTickets = { shown: TicketBadge[]; more: number; newCount: number }
8
+
9
+ export const SHOWN_TICKETS = 3
10
+
11
+ const WORD_CHARACTER = /[\p{L}\p{N}_]/u
12
+
13
+ const isWordCharacter = (character: string | undefined): boolean =>
14
+ character !== undefined && WORD_CHARACTER.test(character)
15
+
16
+ /**
17
+ * Whether `text` names `id` as a whole word: no letter, digit or `_` right before or after it, so `T-12` is not
18
+ * found in `T-123`. A plain search per occurrence, no regular expression: a lookbehind over a long plan cost
19
+ * seconds per refresh. An empty id is found nowhere.
20
+ */
21
+ const namesWord = (text: string, id: string): boolean => {
22
+ if (id === '') return false
23
+ for (let at = text.indexOf(id); at !== -1; at = text.indexOf(id, at + 1)) {
24
+ if (!isWordCharacter(text[at - 1]) && !isWordCharacter(text[at + id.length])) return true
25
+ }
26
+ return false
27
+ }
28
+
29
+ const belongsTo = (ticket: SourceTicket, lane: string, texts: readonly string[]): boolean =>
30
+ ticket.lane === lane || [lane, ...texts].some((text) => namesWord(text, ticket.id))
31
+
32
+ const onceById = (tickets: readonly SourceTicket[]): SourceTicket[] =>
33
+ tickets.filter((ticket, index) => tickets.findIndex(({ id }) => id === ticket.id) === index)
34
+
35
+ const badgeOf = ({ lane: _lane, ...badge }: SourceTicket): TicketBadge => badge
36
+
37
+ /** What the lane's board rows say of it beside its name, for finding ticket ids: the text of each row. */
38
+ export const boardRowTexts = (lane: SprintLane): string[] => lane.steps.map(({ label }) => label)
39
+
40
+ /**
41
+ * The tickets of `lane`: those of a source that names it, and those whose id the lane's name or `texts` (its board
42
+ * rows and its plans) name as a whole word, as written. A ticket listed twice counts once, from its first source.
43
+ * New tickets come first, the rest keep their order; null when the lane has none, so the view draws nothing.
44
+ */
45
+ export const laneTicketsOf = (
46
+ tickets: readonly SourceTicket[],
47
+ lane: string,
48
+ texts: readonly string[],
49
+ ): LaneTickets | null => {
50
+ const own = onceById(tickets.filter((ticket) => belongsTo(ticket, lane, texts))).map(badgeOf)
51
+ if (own.length === 0) return null
52
+ const fresh = own.filter(({ isNew }) => isNew)
53
+ const ordered = [...fresh, ...own.filter(({ isNew }) => !isNew)]
54
+ return {
55
+ ...shownWithMore(ordered, SHOWN_TICKETS),
56
+ newCount: fresh.length,
57
+ }
58
+ }
59
+
60
+ /** The lane's link chips without those whose URL a shown ticket badge opens: the badge takes the chip's place. */
61
+ export const withoutTicketLinks = (
62
+ chips: Record<string, LinkChip[]>,
63
+ tickets: LaneTickets,
64
+ ): Record<string, LinkChip[]> => {
65
+ const shown = new Set(tickets.shown.flatMap(({ url }) => (url ? [url] : [])))
66
+ return Object.fromEntries(
67
+ Object.entries(chips).map(([href, list]) => [href, list.filter(({ url }) => !shown.has(url))]),
68
+ )
69
+ }