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.
- package/CHANGELOG.md +31 -0
- package/INSTALL.md +176 -0
- package/README.md +228 -312
- package/assets/branding/README.md +7 -3
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -1
- package/assets/icons/bark-dark.svg +1 -0
- package/assets/icons/bark.svg +1 -0
- package/assets/icons/bowl-dark.svg +1 -0
- package/assets/icons/bowl.svg +1 -0
- package/assets/icons/decide-dark.svg +1 -0
- package/assets/icons/decide.svg +1 -0
- package/assets/icons/ear-dark.svg +1 -0
- package/assets/icons/ear.svg +1 -0
- package/assets/icons/lanes-dark.svg +1 -0
- package/assets/icons/lanes.svg +1 -0
- package/assets/icons/plan-dark.svg +1 -0
- package/assets/icons/plan.svg +1 -0
- package/assets/icons/space.svg +1 -0
- package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
- package/dist/config.d.ts +14 -3
- package/dist/config.js +111 -10
- package/dist/plan-format.d.ts +180 -77
- package/dist/plan-format.js +292 -93
- package/docs/lanes-dark.svg +50 -0
- package/docs/lanes.svg +50 -1
- package/docs/skills-dark.svg +65 -0
- package/docs/skills.svg +65 -1
- package/docs/workflow-dark.svg +138 -0
- package/docs/workflow.svg +135 -169
- package/herdr-plugin/herdr-plugin.toml +6 -6
- package/herdr-plugin/open-plans.ts +1 -1
- package/herdr-plugin/restore-plans.ts +7 -7
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
- package/kit/hooks/hooks/approval-gate/index.ts +69 -0
- package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
- package/kit/hooks/hooks/approval-gate/request.ts +111 -0
- package/kit/hooks/hooks/approval-gate/run.ts +94 -0
- package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
- package/kit/hooks/hooks/approval-gate/store.ts +70 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
- package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
- package/kit/hooks/hooks/merge-guard/run.ts +2 -1
- package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
- package/kit/hooks/hooks/model-gate/index.ts +58 -0
- package/kit/hooks/hooks/model-gate/run.ts +63 -0
- package/kit/hooks/hooks/model-log/index.ts +60 -0
- package/kit/hooks/hooks/model-log/run.ts +57 -0
- package/kit/hooks/hooks/model-log/store.ts +40 -0
- package/kit/hooks/hooks/model-log/transcript.ts +37 -0
- package/kit/hooks/hooks/shared/checkout.ts +22 -0
- package/kit/hooks/hooks/shared/command.ts +6 -0
- package/kit/hooks/hooks/shared/config.ts +21 -0
- package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
- package/kit/hooks/hooks/shared/files.ts +19 -0
- package/kit/hooks/hooks/shared/fresh.ts +4 -0
- package/kit/hooks/hooks/shared/json.ts +4 -0
- package/kit/hooks/hooks/shared/model-family.ts +48 -0
- package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
- package/kit/hooks/hooks/shared/private-directory.ts +29 -0
- package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
- package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
- package/kit/hooks/hooks.json +25 -0
- package/kit/workflow/skills/follow/SKILL.md +4 -3
- package/kit/workflow/skills/implement/SKILL.md +6 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +109 -27
- package/kit/workflow/skills/plan/SKILL.md +5 -5
- package/kit/workflow/skills/start/SKILL.md +2 -2
- package/package.json +4 -1
- package/schemas/jackherd.schema.json +43 -5
- package/src/check/command.ts +31 -4
- package/src/check/deferral-arrows.ts +1 -1
- package/src/check/parallel-report.ts +23 -0
- package/src/check/plan-structure-check.ts +120 -53
- package/src/cli.ts +8 -2
- package/src/config/legacy-names.ts +8 -0
- package/src/config/nested-quantifier.ts +73 -0
- package/src/config/repository-directory.ts +11 -0
- package/src/config/schema.ts +63 -8
- package/src/plan-format/board-prose.ts +36 -0
- package/src/plan-format/dependencies.ts +227 -0
- package/src/plan-format/documentation-gate.ts +16 -0
- package/src/plan-format/index.ts +23 -1
- package/src/plan-format/lanes.ts +35 -1
- package/src/plan-format/model-column.ts +52 -0
- package/src/plan-format/phases.ts +16 -1
- package/src/plan-format/plan-structure.ts +10 -28
- package/src/plan-format/progress.ts +7 -29
- package/src/plans/command.ts +3 -3
- package/src/plans/current-phase.ts +71 -0
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/herd-command.ts +4 -0
- package/src/plans/lane-discovery.ts +14 -2
- package/src/plans/lane-tickets.ts +69 -0
- package/src/plans/layout.ts +182 -88
- package/src/plans/live.ts +5 -1
- package/src/plans/phase-models-read.ts +37 -0
- package/src/plans/phase-models.ts +70 -0
- package/src/plans/plan-panes.ts +8 -7
- package/src/plans/pull-requests.ts +78 -4
- package/src/plans/selection.ts +1 -40
- package/src/plans/settings.ts +9 -3
- package/src/plans/shown-with-more.ts +5 -0
- package/src/plans/snapshot.ts +82 -8
- package/src/plans/sprint-html.ts +2 -2
- package/src/plans/supervisor.ts +5 -4
- package/src/plans/symbols.ts +9 -6
- package/src/plans/texts.ts +30 -13
- package/src/plans/view.ts +1 -0
- package/src/sync/command.ts +16 -1
- package/src/sync/herdr-plugin.ts +1 -1
- package/src/sync/next-steps.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/setup-questions.ts +2 -2
- package/src/terminal/text.ts +4 -0
- package/src/terminal/types.ts +1 -1
- package/src/update/command.ts +4 -2
- package/src/update/update.ts +33 -9
- package/src/vocabulary/index.ts +39 -0
- package/src/watch/approvals.ts +96 -0
- package/src/watch/command.ts +154 -50
- package/src/watch/failed-tests.ts +52 -0
- package/src/watch/failure-read.ts +56 -0
- package/src/watch/failures.ts +107 -0
- package/src/watch/feedback-item.ts +21 -0
- package/src/watch/feedback-read.ts +2 -1
- package/src/watch/feedback.ts +37 -28
- package/src/watch/gh-process.ts +53 -0
- package/src/watch/gh.ts +10 -20
- package/src/watch/heartbeat-progress.ts +1 -1
- package/src/watch/lane-model.ts +54 -0
- package/src/watch/lane-parallel.ts +46 -0
- package/src/watch/lane-progress.ts +28 -4
- package/src/watch/lane-report.ts +62 -9
- package/src/watch/lane-sources.ts +1 -1
- package/src/watch/no-finding.ts +47 -0
- package/src/watch/plan-badges-read.ts +19 -7
- package/src/watch/plan-badges.ts +59 -7
- package/src/watch/plan-views.ts +5 -5
- package/src/watch/previews.ts +3 -9
- package/src/watch/pull-requests.ts +12 -2
- package/src/watch/recorded-reads.ts +25 -0
- package/src/watch/source-fields.ts +4 -0
- package/src/watch/source-tickets.ts +99 -0
- package/src/watch/sources.ts +62 -15
- package/src/watch/texts.ts +21 -1
- package/src/watch/watch-defaults.ts +8 -0
- package/src/watch/watch-events.ts +64 -4
- package/src/watch/watcher-lock.ts +1 -1
- package/src/watch/workflow-runs.ts +5 -7
- package/docs/lanes.mmd +0 -9
- package/docs/skills.mmd +0 -10
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
-
import {
|
|
3
|
-
import { escapeRegExp,
|
|
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 =
|
|
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 =
|
|
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
|
|
122
|
-
*
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 =
|
|
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 =
|
|
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 ?
|
|
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
|
|
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. */
|
package/src/plans/command.ts
CHANGED
|
@@ -27,8 +27,8 @@ const printOnce = async (source: Source): Promise<void> => {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* `jh
|
|
31
|
-
* progress; `--ensure` instead brings back the missing
|
|
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
|
|
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
|
|
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
|
-
* `
|
|
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
|
*/
|
|
@@ -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
|
-
|
|
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
|
+
}
|