jackherd 1.8.0 → 1.9.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 +39 -0
- package/INSTALL.md +2 -0
- package/README.md +41 -60
- package/assets/branding/README.md +5 -7
- package/assets/branding/jackherd-logo-outline.svg +1 -0
- package/assets/branding/outline.test.ts +30 -0
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-M6DDZEW4.js → chunk-MCP4XJQH.js} +17 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +19 -3
- package/dist/plan-format.d.ts +22 -3
- package/dist/plan-format.js +21 -7
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +70 -21
- package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
- package/kit/hooks/hooks/sent-file/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +18 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks.json +9 -0
- package/kit/hooks/statusline.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +2 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +31 -6
- package/package.json +3 -1
- package/schemas/jackherd.schema.json +26 -2
- package/src/cli.ts +3 -0
- package/src/config/read-config.ts +5 -1
- package/src/config/schema.ts +22 -2
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/plan-format/clock.ts +4 -0
- package/src/plan-format/dependencies.ts +2 -5
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +48 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/lane-standing.ts +12 -6
- package/src/plans/lane-summary.ts +141 -74
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +57 -18
- package/src/plans/layout-summary.ts +85 -67
- package/src/plans/layout.ts +253 -89
- package/src/plans/live.ts +22 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/pull-requests.ts +6 -0
- package/src/plans/selection.ts +19 -16
- package/src/plans/sent-files-read.ts +22 -0
- package/src/plans/sent-files.ts +121 -0
- package/src/plans/signals-read.ts +54 -0
- package/src/plans/snapshot.ts +251 -55
- package/src/plans/subagents-read.ts +82 -0
- package/src/plans/subagents.ts +132 -0
- package/src/plans/texts.ts +16 -11
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +3 -3
- package/src/plans/waiting-click.ts +76 -0
- package/src/preview/command.ts +83 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-records.ts +48 -0
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +72 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +8 -1
- package/src/terminal/width.ts +2 -1
- package/src/update/command.ts +2 -0
- package/src/update/config-shares.ts +30 -0
- package/src/update/update.ts +12 -1
- package/src/vocabulary/index.ts +33 -1
- package/src/waiting/command.ts +35 -0
- package/src/watch/appointments.ts +5 -5
- package/src/watch/approvals.ts +2 -2
- package/src/watch/command.ts +91 -5
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +19 -7
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +2 -1
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +1 -1
- package/src/watch/stream-output.ts +12 -7
- package/src/watch/task-parallel-input.ts +118 -0
- package/src/watch/task-parallel-read.ts +149 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +206 -0
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +13 -1
- package/src/watch/workflow-runs.ts +2 -1
- package/assets/icons/bark-dark.svg +0 -1
- package/assets/icons/bowl-dark.svg +0 -1
- package/assets/icons/decide-dark.svg +0 -1
- package/assets/icons/ear-dark.svg +0 -1
- package/assets/icons/lanes-dark.svg +0 -1
- package/assets/icons/plan-dark.svg +0 -1
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { parseLogEntry } from '../context/session-log'
|
|
1
2
|
import type { PlanPhase } from '../plan-format'
|
|
3
|
+
import { isRecord } from '../shared/is-record'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* A hint that a phase's task is being worked on: `taskIndex` is the task's index in the phase's checkboxes, `at` when
|
|
5
|
-
* it was seen, in milliseconds.
|
|
7
|
+
* it was seen, in milliseconds. `taskSignalsOf` derives them from what the lane's agents did lately.
|
|
6
8
|
*/
|
|
7
|
-
export type
|
|
9
|
+
export type TaskActivity = { phaseId: string; taskIndex: number; at: number }
|
|
8
10
|
|
|
9
11
|
/** A phase as `currentTasks` reads it: its id and checkboxes. */
|
|
10
12
|
export type TaskPhase = Pick<PlanPhase, 'id' | 'items'>
|
|
@@ -14,14 +16,17 @@ const isOpenTask = (phase: TaskPhase, index: number): boolean => phase.items[ind
|
|
|
14
16
|
/** The task the newest signal younger than `staleAfterMs` points at, when it is still open; null otherwise. */
|
|
15
17
|
const signalledTask = (
|
|
16
18
|
phase: TaskPhase,
|
|
17
|
-
signals: readonly
|
|
19
|
+
signals: readonly TaskActivity[],
|
|
18
20
|
now: number,
|
|
19
21
|
staleAfterMs: number,
|
|
20
22
|
): number | null => {
|
|
21
23
|
const fresh = signals
|
|
22
24
|
.filter((signal) => signal.phaseId === phase.id && now - signal.at < staleAfterMs)
|
|
23
25
|
.filter((signal) => isOpenTask(phase, signal.taskIndex))
|
|
24
|
-
const newest = fresh.reduce<
|
|
26
|
+
const newest = fresh.reduce<TaskActivity | null>(
|
|
27
|
+
(last, signal) => (last && last.at >= signal.at ? last : signal),
|
|
28
|
+
null,
|
|
29
|
+
)
|
|
25
30
|
return newest?.taskIndex ?? null
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -38,7 +43,7 @@ const firstOpenTask = ({ items }: TaskPhase): number => {
|
|
|
38
43
|
*/
|
|
39
44
|
export const currentTasks = (
|
|
40
45
|
plan: readonly TaskPhase[],
|
|
41
|
-
signals: readonly
|
|
46
|
+
signals: readonly TaskActivity[],
|
|
42
47
|
now: number,
|
|
43
48
|
staleAfterMs: number,
|
|
44
49
|
): ReadonlyMap<string, number> =>
|
|
@@ -49,3 +54,56 @@ export const currentTasks = (
|
|
|
49
54
|
return [[phase.id, signalledTask(phase, signals, now, staleAfterMs) ?? first] as const]
|
|
50
55
|
}),
|
|
51
56
|
)
|
|
57
|
+
|
|
58
|
+
const WORD = /[a-zäöüß][a-zäöüß0-9-]{5,}/g
|
|
59
|
+
/** The share of a task's distinctive words the agent used, from which on it counts as working on that task. */
|
|
60
|
+
const SIGNAL_SHARE = 0.4
|
|
61
|
+
const SIGNAL_MIN_WORDS = 3
|
|
62
|
+
/** Only the start of a task's text is read: the rest is reasons and origin. */
|
|
63
|
+
const TASK_TEXT_LENGTH = 200
|
|
64
|
+
|
|
65
|
+
const wordsOf = (text: string): Set<string> => new Set(text.toLowerCase().match(WORD) ?? [])
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The signal each phase gets from what an agent of the lane did lately (`activity`, see `activityTextOf`): the open
|
|
69
|
+
* task of which the agent used at least `SIGNAL_SHARE` of the distinctive words (six letters or more, from the first
|
|
70
|
+
* 200 characters) and at least three, and clearly the most of all open tasks of the phase. Measured against the logs of
|
|
71
|
+
* the lane's agents: the prompt alone names several tasks, the words of the last entries single one out; the first open
|
|
72
|
+
* task stays the fallback when no task leads.
|
|
73
|
+
*/
|
|
74
|
+
export const taskSignalsOf = (plan: readonly TaskPhase[], activity: string, at: number): TaskActivity[] => {
|
|
75
|
+
const used = wordsOf(activity)
|
|
76
|
+
if (used.size === 0) return []
|
|
77
|
+
return plan.flatMap((phase) => {
|
|
78
|
+
const scored = phase.items.flatMap((item, taskIndex) => {
|
|
79
|
+
if (item.done) return []
|
|
80
|
+
const words = [...wordsOf(item.text.slice(0, TASK_TEXT_LENGTH))]
|
|
81
|
+
const hits = words.filter((word) => used.has(word)).length
|
|
82
|
+
return words.length > 0 && hits >= SIGNAL_MIN_WORDS && hits / words.length >= SIGNAL_SHARE
|
|
83
|
+
? [{ taskIndex, hits }]
|
|
84
|
+
: []
|
|
85
|
+
})
|
|
86
|
+
const [best, second] = scored.sort((left, right) => right.hits - left.hits)
|
|
87
|
+
return best && best.hits > (second?.hits ?? 0) ? [{ phaseId: phase.id, taskIndex: best.taskIndex, at }] : []
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const contentTexts = (content: unknown): string[] => {
|
|
92
|
+
if (typeof content === 'string') return [content]
|
|
93
|
+
if (!Array.isArray(content)) return []
|
|
94
|
+
return content.flatMap((block) => {
|
|
95
|
+
if (typeof block !== 'object' || block === null) return []
|
|
96
|
+
const { text, input } = block as { text?: unknown; input?: unknown }
|
|
97
|
+
return [typeof text === 'string' ? text : '', input === undefined ? '' : JSON.stringify(input)]
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The text of an agent's last `entries` log entries (messages and tool inputs), for `taskSignalsOf`; lines that are no JSON are skipped. */
|
|
102
|
+
export const activityTextOf = (lines: readonly string[], entries: number): string =>
|
|
103
|
+
lines
|
|
104
|
+
.slice(-entries)
|
|
105
|
+
.flatMap((line) => {
|
|
106
|
+
const message = parseLogEntry(line)?.message
|
|
107
|
+
return isRecord(message) ? contentTexts(message.content) : []
|
|
108
|
+
})
|
|
109
|
+
.join(' ')
|
|
@@ -10,7 +10,7 @@ export type LaneStanding = (typeof LANE_STANDING)[keyof typeof LANE_STANDING]
|
|
|
10
10
|
|
|
11
11
|
type StandingInput = {
|
|
12
12
|
progress: Pick<LaneProgress, 'lane' | 'done' | 'total'>
|
|
13
|
-
status: { agent: string | null; branch: string | null }
|
|
13
|
+
status: { agent: string | null; branch: string | null; dirty: number }
|
|
14
14
|
pullRequests: readonly unknown[]
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -45,15 +45,21 @@ const hasOpenWork = (progress: StandingInput['progress']): boolean => {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Where a lane stands on the board.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* Where a lane stands on the board. An agent, an open pull request or uncommitted changes in its worktree make a
|
|
49
|
+
* lane active (a small fix has no agent in herdr). The cleaned-up mark goes first, but uncommitted changes in the
|
|
50
|
+
* worktree beat it: work is going on, whatever the board says. Otherwise the mark closes a lane, unless an agent, a
|
|
51
|
+
* worktree or a pull request is still there, which keeps it listed. A worktree alone lists a lane. Without a
|
|
52
|
+
* worktree a lane waits while it has open work, else it is closed.
|
|
51
53
|
*/
|
|
52
54
|
export const laneStanding = (input: StandingInput): LaneStanding => {
|
|
53
55
|
const { progress, status, pullRequests } = input
|
|
54
56
|
const working = status.agent !== null || pullRequests.length > 0
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
+
const editedInWorktree = status.branch !== null && status.dirty > 0
|
|
58
|
+
if (progress.lane.closed) {
|
|
59
|
+
if (editedInWorktree) return LANE_STANDING.active
|
|
60
|
+
return working || status.branch !== null ? LANE_STANDING.noAgent : LANE_STANDING.closed
|
|
61
|
+
}
|
|
62
|
+
if (working || editedInWorktree) return LANE_STANDING.active
|
|
57
63
|
if (status.branch !== null) return LANE_STANDING.noAgent
|
|
58
64
|
return hasOpenWork(progress) ? LANE_STANDING.waiting : LANE_STANDING.closed
|
|
59
65
|
}
|
|
@@ -6,32 +6,31 @@ import {
|
|
|
6
6
|
type PhaseProgress,
|
|
7
7
|
type PlanPhase,
|
|
8
8
|
type PlansByHref,
|
|
9
|
-
parseDependencyTable,
|
|
10
9
|
phaseProgress,
|
|
11
|
-
phaseWaves,
|
|
12
10
|
} from '../plan-format'
|
|
13
|
-
import type {
|
|
11
|
+
import type { TaskSignal } from '../task-signals/types'
|
|
12
|
+
import { type ActiveTask, assignSignals, freshSignals, type UnplannedTask } from './active-tasks'
|
|
14
13
|
import { type ActivityTimes, activityKey } from './current-phase'
|
|
15
|
-
import { currentTasks, type
|
|
14
|
+
import { currentTasks, type TaskActivity } from './current-task'
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
|
-
* A phase the lane works on now: an open one
|
|
19
|
-
* `stepIndex` is the newest step of the lane that shows it, null for a phase no step shows;
|
|
20
|
-
* minutes since
|
|
17
|
+
* A phase the lane works on now: an open one ticked less than `STALE_ACTIVITY_MINUTES` ago, one an agent reports, or
|
|
18
|
+
* one with a ticked task. `stepIndex` is the newest step of the lane that shows it, null for a phase no step shows;
|
|
19
|
+
* `minutesAgo` the whole minutes since its last tick or report, null when the herd saw neither.
|
|
21
20
|
*/
|
|
22
21
|
export type ActivePhase = {
|
|
23
22
|
phase: PhaseProgress
|
|
24
23
|
stepIndex: number | null
|
|
25
|
-
minutesAgo: number
|
|
26
|
-
/**
|
|
27
|
-
|
|
24
|
+
minutesAgo: number | null
|
|
25
|
+
/**
|
|
26
|
+
* The tasks the phase is on: the ones agents reported, else the one `currentTasks` guesses (marked `guessed`); empty
|
|
27
|
+
* when no task is open.
|
|
28
|
+
*/
|
|
29
|
+
tasks: ActiveTask[]
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
/**
|
|
31
|
-
export type
|
|
32
|
-
|
|
33
|
-
/** Open phases after the next ones; `planTitle` is null for the lane's current plan, the board's title for another. */
|
|
34
|
-
export type LaterPhases = { planTitle: string | null; phaseIds: string[] }
|
|
32
|
+
/** Open phases that do not run, by number; `planTitle` is null for the lane's current plan, the board's title for another. */
|
|
33
|
+
export type WaitingPhases = { planTitle: string | null; phaseIds: string[] }
|
|
35
34
|
|
|
36
35
|
export type FinishedPhase = { id: string; unmerged: boolean }
|
|
37
36
|
|
|
@@ -41,35 +40,36 @@ export type UnwrittenPhase = { id: string; missing: boolean }
|
|
|
41
40
|
/** Checkboxes and phases over every plan of the lane, a phase counted when it has a checkbox. */
|
|
42
41
|
export type PlanTally = { done: number; total: number; phasesDone: number; phases: number }
|
|
43
42
|
|
|
44
|
-
/** A lane as the herd sums it up: what runs now, what
|
|
43
|
+
/** A lane as the herd sums it up: what runs now, what waits, what is finished, and how far it is. */
|
|
45
44
|
export type LaneSummary = {
|
|
46
|
-
/** The running phases,
|
|
45
|
+
/** The running phases by number, never by recency, so a new movement reorders nothing. */
|
|
47
46
|
active: ActivePhase[]
|
|
48
47
|
/** The open phase of the current plan moved last, at any age; null while none has a time. */
|
|
49
48
|
latest: PhaseProgress | null
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
/** The open phases of the current plan that do not run, then the open phases of the lane's other plans. */
|
|
50
|
+
waiting: WaitingPhases[]
|
|
52
51
|
finished: FinishedPhase[]
|
|
53
52
|
unwritten: UnwrittenPhase[]
|
|
54
53
|
tally: PlanTally
|
|
54
|
+
/** What agents report doing that no open task of the plan names. */
|
|
55
|
+
unplanned: UnplannedTask[]
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/**
|
|
58
|
-
* `phases` are the lane's plans as it reads them (the base merged with its worktree)
|
|
59
|
-
*
|
|
59
|
+
* `phases` are the lane's plans as it reads them (the base merged with its worktree); a plan missing from `phases`
|
|
60
|
+
* falls back to the phases the lane's steps show.
|
|
60
61
|
*/
|
|
61
62
|
export type SummaryInput = {
|
|
62
63
|
progress: LaneProgress
|
|
63
64
|
phases: Readonly<PlansByHref>
|
|
64
|
-
texts: Readonly<Record<string, string>>
|
|
65
65
|
activity: ActivityTimes
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
|
|
66
|
+
/** Hints at the task a phase is on, guessed from the words of the lane's agents; the fallback while nothing is reported. */
|
|
67
|
+
guesses?: readonly TaskActivity[]
|
|
68
|
+
/** What the lane's agents report doing (task lists, `jh task`), matched to open tasks of the current plan. */
|
|
69
|
+
reported?: readonly TaskSignal[]
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/** Minutes without a tick
|
|
72
|
+
/** Minutes without a tick after which a phase no longer runs, unless an agent reports it. */
|
|
73
73
|
export const STALE_ACTIVITY_MINUTES = 20
|
|
74
74
|
|
|
75
75
|
const MINUTE = 60_000
|
|
@@ -92,9 +92,27 @@ const stepPhases = ({ steps }: LaneProgress, href: string): PhaseProgress[] => {
|
|
|
92
92
|
return shown.filter((phase, index) => shown.findIndex(({ id }) => id === phase.id) === index)
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
const SUB_PHASE_LETTERS = /[a-z]+$/i
|
|
96
|
+
|
|
97
|
+
/** The ids the lane's steps on `href` name, a row's repeats and the phases it worked on without a row included. */
|
|
98
|
+
const namedIds = ({ steps }: LaneProgress, href: string): Set<string> =>
|
|
99
|
+
new Set(
|
|
100
|
+
steps.flatMap(({ step }) => (step.planHref === href ? [...step.phaseIds, ...(step.namedPhaseIds ?? [])] : [])),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
/** A plan phase the lane names: by its own id, or a sub-phase (`10c`) of a phase it names (`P10`). */
|
|
104
|
+
const isNamed = (id: string, named: ReadonlySet<string>): boolean =>
|
|
105
|
+
named.has(id) || named.has(id.replace(SUB_PHASE_LETTERS, ''))
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The phases of `href` the lane names, in plan order: another lane's phases of the same plan are not its own, however
|
|
109
|
+
* lately they moved.
|
|
110
|
+
*/
|
|
95
111
|
const phasesOf = (input: SummaryInput, href: string): PhaseProgress[] => {
|
|
96
112
|
const plan: readonly PlanPhase[] | undefined = input.phases[href]
|
|
97
|
-
|
|
113
|
+
if (!plan) return stepPhases(input.progress, href)
|
|
114
|
+
const named = namedIds(input.progress, href)
|
|
115
|
+
return plan.filter(({ id }) => isNamed(id, named)).map(({ id }) => phaseProgress(plan, id))
|
|
98
116
|
}
|
|
99
117
|
|
|
100
118
|
const newestStepShowing = ({ steps }: LaneProgress, href: string, id: string): number | null => {
|
|
@@ -106,6 +124,17 @@ const newestStepShowing = ({ steps }: LaneProgress, href: string, id: string): n
|
|
|
106
124
|
|
|
107
125
|
type TimedPhase = { phase: PhaseProgress; minutesAgo: number }
|
|
108
126
|
|
|
127
|
+
type RunningPhase = { phase: PhaseProgress; minutesAgo: number | null }
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A phase whose tasks outside the review gate are partly ticked: work on it has begun and goes on. One with only its
|
|
131
|
+
* review gate open waits for its review, as in `jh check --parallel`.
|
|
132
|
+
*/
|
|
133
|
+
const isStarted = ({ items }: PhaseProgress): boolean => {
|
|
134
|
+
const tasks = items.filter(({ gate }) => !gate)
|
|
135
|
+
return tasks.some(({ done }) => done) && tasks.some(({ done }) => !done)
|
|
136
|
+
}
|
|
137
|
+
|
|
109
138
|
/** The open phases with a time of their last movement, the one moved last first; a phase without a time is absent. */
|
|
110
139
|
const timedPhases = (input: SummaryInput, href: string, phases: PhaseProgress[], now: number): TimedPhase[] =>
|
|
111
140
|
phases
|
|
@@ -116,42 +145,77 @@ const timedPhases = (input: SummaryInput, href: string, phases: PhaseProgress[],
|
|
|
116
145
|
})
|
|
117
146
|
.sort((first, second) => first.minutesAgo - second.minutesAgo)
|
|
118
147
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
148
|
+
const newest = (times: readonly number[]): number | null => (times.length > 0 ? Math.max(...times) : null)
|
|
149
|
+
|
|
150
|
+
const minutesSince = (at: number, now: number): number => Math.max(Math.floor((now - at) / MINUTE), 0)
|
|
151
|
+
|
|
152
|
+
const isListed = (listed: readonly RunningPhase[], phase: PhaseProgress): boolean =>
|
|
153
|
+
listed.some((each) => each.phase.id === phase.id)
|
|
154
|
+
|
|
155
|
+
/** The open phases a reported task names that are not `listed` yet, aged from the newest report. */
|
|
156
|
+
const reportedPhases = (
|
|
157
|
+
phases: PhaseProgress[],
|
|
158
|
+
reported: ReadonlyMap<string, ActiveTask[]>,
|
|
159
|
+
listed: readonly RunningPhase[],
|
|
160
|
+
now: number,
|
|
161
|
+
): RunningPhase[] =>
|
|
162
|
+
phases
|
|
163
|
+
.filter((phase) => isPhaseOpen(phase) && reported.has(phase.id) && !isListed(listed, phase))
|
|
164
|
+
.map((phase) => {
|
|
165
|
+
const startedAt = newest((reported.get(phase.id) ?? []).flatMap((task) => task.startedAt ?? []))
|
|
166
|
+
return { phase, minutesAgo: startedAt === null ? 0 : minutesSince(startedAt, now) }
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
/** The open phases with a ticked task (`isStarted`) that are not `listed` yet, aged from their last tick when one is known. */
|
|
170
|
+
const startedPhases = (phases: PhaseProgress[], timed: TimedPhase[], listed: readonly RunningPhase[]): RunningPhase[] =>
|
|
171
|
+
phases
|
|
172
|
+
.filter((phase) => isPhaseOpen(phase) && isStarted(phase) && !isListed(listed, phase))
|
|
173
|
+
.map((phase) => ({ phase, minutesAgo: timed.find((each) => each.phase.id === phase.id)?.minutesAgo ?? null }))
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The phases that run: the timed ones ticked less than `STALE_ACTIVITY_MINUTES` ago, the phases a reported task
|
|
177
|
+
* names, then every other open phase with a ticked task (`isStarted`), with the age of its last tick when one is
|
|
178
|
+
* known, else none (17:38). A phase only written down, without a tick or a report, waits.
|
|
179
|
+
*/
|
|
180
|
+
const runningPhases = (
|
|
181
|
+
timed: TimedPhase[],
|
|
182
|
+
phases: PhaseProgress[],
|
|
183
|
+
reported: ReadonlyMap<string, ActiveTask[]>,
|
|
184
|
+
now: number,
|
|
185
|
+
): RunningPhase[] => {
|
|
186
|
+
const moved = timed.filter(({ minutesAgo }) => minutesAgo < STALE_ACTIVITY_MINUTES)
|
|
187
|
+
const listed = [...moved, ...reportedPhases(phases, reported, moved, now)]
|
|
188
|
+
return [...listed, ...startedPhases(phases, timed, listed)]
|
|
134
189
|
}
|
|
135
190
|
|
|
136
191
|
/**
|
|
137
|
-
* The
|
|
138
|
-
*
|
|
192
|
+
* The task the herd guesses for each of the `phases` from the words of the lane's agents (`currentTasks`); the caller
|
|
193
|
+
* passes no phase while an agent reports, so a lane with a report guesses nothing.
|
|
139
194
|
*/
|
|
140
|
-
const
|
|
195
|
+
const guessedTasks = (input: SummaryInput, phases: PhaseProgress[], now: number): ReadonlyMap<string, number> =>
|
|
196
|
+
currentTasks(phases, input.guesses ?? [], now, STALE_ACTIVITY_MINUTES * MINUTE)
|
|
197
|
+
|
|
198
|
+
/** The running phases, each with the tasks agents reported on it, else its task in `guessed`, marked as guessed. */
|
|
199
|
+
const activeOf = (
|
|
141
200
|
input: SummaryInput,
|
|
142
201
|
href: string,
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
202
|
+
running: RunningPhase[],
|
|
203
|
+
reported: ReadonlyMap<string, ActiveTask[]>,
|
|
204
|
+
guessed: ReadonlyMap<string, number>,
|
|
205
|
+
): ActivePhase[] =>
|
|
206
|
+
running
|
|
207
|
+
.map(({ phase, minutesAgo }) => {
|
|
208
|
+
const guess = guessed.get(phase.id)
|
|
209
|
+
return {
|
|
210
|
+
phase,
|
|
211
|
+
stepIndex: newestStepShowing(input.progress, href, phase.id),
|
|
212
|
+
minutesAgo,
|
|
213
|
+
tasks:
|
|
214
|
+
reported.get(phase.id) ??
|
|
215
|
+
(guess === undefined ? [] : [{ index: guess, agent: null, guessed: true, startedAt: null }]),
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
.sort((first, second) => first.phase.id.localeCompare(second.phase.id, undefined, { numeric: true }))
|
|
155
219
|
|
|
156
220
|
const tallyOf = (phases: PhaseProgress[]): PlanTally => ({
|
|
157
221
|
done: phases.reduce((sum, phase) => sum + phase.done, 0),
|
|
@@ -164,21 +228,21 @@ const titleOf = ({ steps }: LaneProgress, href: string): string =>
|
|
|
164
228
|
steps.find(({ step }) => step.planHref === href)?.step.planTitle ?? href
|
|
165
229
|
|
|
166
230
|
/** The open phases of the lane's other plans, each plan by its title. */
|
|
167
|
-
const otherPlans = (input: SummaryInput, hrefs: string[]):
|
|
231
|
+
const otherPlans = (input: SummaryInput, hrefs: string[]): WaitingPhases[] =>
|
|
168
232
|
hrefs.flatMap((href) => {
|
|
169
233
|
const open = phasesOf(input, href).filter(isPhaseOpen)
|
|
170
234
|
return open.length > 0 ? [{ planTitle: titleOf(input.progress, href), phaseIds: open.map(({ id }) => id) }] : []
|
|
171
235
|
})
|
|
172
236
|
|
|
173
|
-
/** The open phases of the current plan that
|
|
174
|
-
const
|
|
237
|
+
/** The open phases of the current plan that do not run, by number, then the open phases of the lane's other plans. */
|
|
238
|
+
const waitingOf = (
|
|
175
239
|
input: SummaryInput,
|
|
176
240
|
href: string,
|
|
177
241
|
phases: PhaseProgress[],
|
|
178
|
-
|
|
179
|
-
):
|
|
242
|
+
running: ReadonlySet<string>,
|
|
243
|
+
): WaitingPhases[] => {
|
|
180
244
|
const waiting = phases
|
|
181
|
-
.filter((phase) => isPhaseOpen(phase) && !
|
|
245
|
+
.filter((phase) => isPhaseOpen(phase) && !running.has(phase.id))
|
|
182
246
|
.map(({ id }) => id)
|
|
183
247
|
.sort((first, second) => first.localeCompare(second, undefined, { numeric: true }))
|
|
184
248
|
const others = otherPlans(
|
|
@@ -193,30 +257,33 @@ const finishedOf = (phases: PhaseProgress[]): FinishedPhase[] =>
|
|
|
193
257
|
phases.filter(isPhaseFinished).map((phase) => ({ id: phase.id, unmerged: isTickedUnmerged(phase) }))
|
|
194
258
|
|
|
195
259
|
/**
|
|
196
|
-
* The lane summed up over its plans at `now`: the open phases running in its current plan (
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* rows name with nothing to run, and the tally over every plan of the lane. A phase ticked to the end, review gate
|
|
260
|
+
* The lane summed up over its plans at `now`: the open phases running in its current plan (`runningPhases`), the open
|
|
261
|
+
* phase moved last, the open phases that wait, the finished ones (marked when not merged yet), the phases its rows
|
|
262
|
+
* name with nothing to run, and the tally over every plan of the lane. A phase ticked to the end, review gate
|
|
200
263
|
* included, is finished, never running.
|
|
201
264
|
*/
|
|
202
265
|
export const laneSummary = (input: SummaryInput, now: number): LaneSummary => {
|
|
203
266
|
const href = focusHref(input.progress)
|
|
204
267
|
const tally = tallyOf(planHrefs(input.progress).flatMap((each) => phasesOf(input, each)))
|
|
205
|
-
if (href === null)
|
|
268
|
+
if (href === null) {
|
|
269
|
+
return { active: [], latest: null, waiting: [], finished: [], unwritten: [], tally, unplanned: [] }
|
|
270
|
+
}
|
|
206
271
|
const phases = phasesOf(input, href)
|
|
207
272
|
const timed = timedPhases(input, href, phases, now)
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
const
|
|
273
|
+
const fresh = freshSignals(input.reported ?? [], newest(Object.values(input.activity)))
|
|
274
|
+
const { byPhase, unplanned } = assignSignals(phases, fresh)
|
|
275
|
+
const running = runningPhases(timed, phases, byPhase, now)
|
|
276
|
+
const guessable = fresh.length > 0 ? [] : running.map(({ phase }) => phase).filter(({ id }) => !byPhase.has(id))
|
|
277
|
+
const active = activeOf(input, href, running, byPhase, guessedTasks(input, guessable, now))
|
|
211
278
|
return {
|
|
212
279
|
active,
|
|
213
280
|
latest: timed[0]?.phase ?? null,
|
|
214
|
-
|
|
215
|
-
later: laterOf(input, href, phases, new Set([...running, ...(next?.phaseIds ?? [])])),
|
|
281
|
+
waiting: waitingOf(input, href, phases, new Set(active.map(({ phase }) => phase.id))),
|
|
216
282
|
finished: finishedOf(phases),
|
|
217
283
|
unwritten: stepPhases(input.progress, href)
|
|
218
284
|
.filter((phase) => !isWritten(phase))
|
|
219
285
|
.map(({ id, title }) => ({ id, missing: title === null })),
|
|
220
286
|
tally,
|
|
287
|
+
unplanned,
|
|
221
288
|
}
|
|
222
289
|
}
|
|
@@ -34,6 +34,21 @@ const onceById = (tickets: readonly SourceTicket[]): SourceTicket[] =>
|
|
|
34
34
|
|
|
35
35
|
const badgeOf = ({ lane: _lane, ...badge }: SourceTicket): TicketBadge => badge
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* A pull request title without the lane ticket it starts with (`T-1 …`, `[T-1] …`, `T-1: …`, `T-1 - …`): the ticket
|
|
39
|
+
* stands under the lane's links already. A key inside the title, a longer key or a title that is the key alone stays.
|
|
40
|
+
*/
|
|
41
|
+
export const withoutTicketKey = (title: string, ids: readonly string[]): string => {
|
|
42
|
+
for (const id of ids) {
|
|
43
|
+
const lead = title.startsWith(`[${id}]`) ? `[${id}]` : title.startsWith(id) ? id : ''
|
|
44
|
+
const rest = title.slice(lead.length)
|
|
45
|
+
if (lead === '' || isWordCharacter(rest[0])) continue
|
|
46
|
+
const stripped = rest.replace(/^\s*[:-]?\s+|^\s*:\s*/, '')
|
|
47
|
+
if (stripped !== '' && stripped !== rest) return stripped
|
|
48
|
+
}
|
|
49
|
+
return title
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
/** What the lane's board rows say of it beside its name, for finding ticket ids: the text of each row. */
|
|
38
53
|
export const boardRowTexts = (lane: SprintLane): string[] => lane.steps.map(({ label }) => label)
|
|
39
54
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { ChecklistItem, DeferredItem, LaneProgress, PhaseProgress } from '../plan-format'
|
|
2
2
|
import { onlyDocumentationOpen, phaseLabel } from '../plan-format'
|
|
3
|
-
import {
|
|
3
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
4
|
+
import { fit, padVisible, paint, spread, wrap } from '../terminal/text'
|
|
4
5
|
import { textWidth } from '../terminal/width'
|
|
5
6
|
import type { WatchBadges } from '../watch/plan-badges'
|
|
7
|
+
import type { ActiveTask, UnplannedTask } from './active-tasks'
|
|
6
8
|
import { type Frame, rowFrame, textFrame } from './frame'
|
|
7
9
|
import type { LaneTickets } from './lane-tickets'
|
|
8
10
|
import type { LinkChip } from './link-chips'
|
|
@@ -26,13 +28,14 @@ export type LaneContext = {
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export const ACTIVE_MARK = '▶'
|
|
31
|
+
export const GUESSED_MARK = '▷'
|
|
29
32
|
export const DEFERRED_MARK = '→'
|
|
30
33
|
export const COLUMN_SEPARATOR = ' '
|
|
31
34
|
export const MIN_TITLE_WIDTH = 8
|
|
32
35
|
|
|
33
36
|
/** Places on each side of the slash of a count: ` 9/9 `, ` 15/15 `, `123/456`. */
|
|
34
37
|
const COUNT_PLACES = 3
|
|
35
|
-
/** The cells before a count:
|
|
38
|
+
/** The cells before a count: `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
|
|
36
39
|
const COUNT_MARK_WIDTH = 2
|
|
37
40
|
const BLANK_COUNT = ' '.repeat(2 * COUNT_PLACES + 1)
|
|
38
41
|
const COUNT_CELL_WIDTH = COUNT_MARK_WIDTH + 1 + BLANK_COUNT.length
|
|
@@ -87,26 +90,62 @@ export const modelMark = (model: PhaseModel | undefined): string =>
|
|
|
87
90
|
: ''
|
|
88
91
|
|
|
89
92
|
/** `text` wrapped beside a one-cell `mark`, each line in `style`; follow-up lines indent under the text. */
|
|
90
|
-
const markedLines = (text: string,
|
|
91
|
-
wrap(plain(text), Math.max(width - 2, 1))
|
|
93
|
+
const markedLines = (text: string, look: TaskLook, width: number): string[] => {
|
|
94
|
+
const lines = wrap(plain(text), Math.max(width - 2, 1))
|
|
95
|
+
return lines.map(
|
|
96
|
+
(line, index) =>
|
|
97
|
+
`${index > 0 ? ' ' : look.mark} ${look.style(line)}${index === lines.length - 1 ? look.suffix : ''}`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The arrow of a phase or task being worked on: a teal `▶` where an agent reported it, a dim teal `▷` where the herd
|
|
103
|
+
* only guesses it from words. Yellow is the herd's colour for what waits on the user.
|
|
104
|
+
*/
|
|
105
|
+
export const activeArrow = (guessed: boolean): string =>
|
|
106
|
+
guessed ? paint.dim(paint.teal(GUESSED_MARK)) : paint.teal(ACTIVE_MARK)
|
|
107
|
+
|
|
108
|
+
/** The title of a phase or task being worked on: bold where reported, plain where guessed. */
|
|
109
|
+
export const activeTitle = (text: string, guessed: boolean): string => (guessed ? text : paint.bold(text))
|
|
110
|
+
|
|
111
|
+
/** The agent's name after a title, dim; nothing while only one agent is active. */
|
|
112
|
+
export const agentSuffix = (agent: string | null): string => (agent ? ` ${paint.dim(terminalSafe(agent))}` : '')
|
|
113
|
+
|
|
114
|
+
/** The agent's name for the right edge of a row, dim; empty while only one agent is active. */
|
|
115
|
+
export const agentCell = (agent: string | null): string => (agent ? paint.dim(terminalSafe(agent)) : '')
|
|
92
116
|
|
|
93
|
-
/**
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
117
|
+
/** Two cells: a task row hangs under its phase's label, which an arrow would otherwise push right. */
|
|
118
|
+
export const TASK_INDENT = ' '
|
|
119
|
+
|
|
120
|
+
/** `▶ now: <text>`: what an agent reports that no task of the plan names; the agent dim at the right edge. */
|
|
121
|
+
export const nowLine = ({ text, agent }: UnplannedTask, width: number): string =>
|
|
122
|
+
spread(`${activeArrow(false)} ${TEXTS.now} ${paint.bold(plain(terminalSafe(text)))}`, agentCell(agent), width)
|
|
123
|
+
|
|
124
|
+
type TaskLook = { mark: string; style: (line: string) => string; suffix: string }
|
|
125
|
+
|
|
126
|
+
/** The mark and the text style of a task: a ticked one green and dim, the one being worked on with its arrow. */
|
|
127
|
+
const taskLook = ({ done }: ChecklistItem, active: ActiveTask | undefined): TaskLook => {
|
|
128
|
+
if (active) {
|
|
129
|
+
return {
|
|
130
|
+
mark: activeArrow(active.guessed),
|
|
131
|
+
style: (line) => activeTitle(line, active.guessed),
|
|
132
|
+
suffix: agentSuffix(active.agent),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return done
|
|
136
|
+
? { mark: paint.green('☑'), style: paint.dim, suffix: '' }
|
|
137
|
+
: { mark: '☐', style: (line) => line, suffix: '' }
|
|
97
138
|
}
|
|
98
139
|
|
|
99
140
|
/** A closed task as one row, cut at the pane's edge with `…`. */
|
|
100
|
-
const closedTaskRow = (item: ChecklistItem, active:
|
|
141
|
+
const closedTaskRow = (item: ChecklistItem, active: ActiveTask | undefined, width: number): string[] => {
|
|
101
142
|
const { mark, style } = taskLook(item, active)
|
|
102
|
-
return [
|
|
143
|
+
return [spread(`${mark} ${style(plain(item.text))}`, agentCell(active?.agent ?? null), width)]
|
|
103
144
|
}
|
|
104
145
|
|
|
105
146
|
/** An open task as rows: its whole text wrapped. */
|
|
106
|
-
const openTaskRows = (item: ChecklistItem, active:
|
|
107
|
-
|
|
108
|
-
return markedLines(item.text, mark, style, width)
|
|
109
|
-
}
|
|
147
|
+
const openTaskRows = (item: ChecklistItem, active: ActiveTask | undefined, width: number): string[] =>
|
|
148
|
+
markedLines(item.text, taskLook(item, active), width)
|
|
110
149
|
|
|
111
150
|
/** A task as a selectable row under its `key`: clickable across every line it takes, the cursor on its first. */
|
|
112
151
|
export const taskFrame = (key: string, rows: string[], context: Pick<LaneContext, 'state' | 'width'>): Frame =>
|
|
@@ -123,20 +162,20 @@ const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number):
|
|
|
123
162
|
})
|
|
124
163
|
|
|
125
164
|
/**
|
|
126
|
-
* The children of an open phase, one frame per task: open ones first, `active`
|
|
165
|
+
* The children of an open phase, one frame per task: open ones first, the `active` ones (by task index) marked, then the ones
|
|
127
166
|
* deferred to another phase, then the ticked ones listed or summed up.
|
|
128
167
|
*/
|
|
129
168
|
export const phaseChildren = (
|
|
130
169
|
context: LaneContext,
|
|
131
170
|
phaseRowKey: string,
|
|
132
171
|
phase: PhaseProgress,
|
|
133
|
-
active:
|
|
172
|
+
active: ReadonlyMap<number, ActiveTask>,
|
|
134
173
|
): Frame[] => {
|
|
135
174
|
const { state, width } = context
|
|
136
175
|
const frameOf = (item: ChecklistItem, index: number): Frame => {
|
|
137
176
|
const key = itemKey(phaseRowKey, index)
|
|
138
|
-
const
|
|
139
|
-
const rows = state.expanded.has(key) ? openTaskRows(item,
|
|
177
|
+
const mark = active.get(index)
|
|
178
|
+
const rows = state.expanded.has(key) ? openTaskRows(item, mark, width) : closedTaskRow(item, mark, width)
|
|
140
179
|
return taskFrame(key, rows, context)
|
|
141
180
|
}
|
|
142
181
|
const framesWhere = (done: boolean): Frame[] =>
|