@marcomigozzi/trailhead 0.13.0 → 0.15.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 (120) hide show
  1. package/README.it.md +142 -29
  2. package/README.md +141 -28
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +17 -10
  8. package/bin/lib/codex-projection.test.js +80 -11
  9. package/bin/lib/install-verify.js +10 -5
  10. package/bin/lib/install-verify.test.js +51 -0
  11. package/bin/report-status-docs.test.js +195 -0
  12. package/bin/resolve-helpers-docs.test.js +36 -0
  13. package/bin/scope-confirm-skip-docs.test.js +70 -0
  14. package/bin/trailhead.js +40 -28
  15. package/bin/trailhead.test.js +503 -6
  16. package/bin/uat-setup-docs.test.js +65 -0
  17. package/package.json +4 -1
  18. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  19. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  21. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  22. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  23. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  24. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  25. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  26. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  27. package/plugins/trailhead/commands/panel.md +10 -0
  28. package/plugins/trailhead/hooks/hooks.json +3 -18
  29. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  30. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  31. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  32. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  33. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  34. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  36. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  37. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  38. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  39. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  40. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  41. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  42. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  43. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  44. package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
  45. package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
  46. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  47. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  48. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  49. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  50. package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
  51. package/plugins/trailhead/hooks/mod/README.md +219 -0
  52. package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
  53. package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
  54. package/plugins/trailhead/hooks/mod/register.tsx +293 -0
  55. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  56. package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
  57. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  58. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  59. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  60. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  61. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  62. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  63. package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
  64. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
  65. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  66. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  67. package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
  68. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
  69. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  70. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  71. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  72. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  73. package/plugins/trailhead/skills/_shared/choices.md +7 -3
  74. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  75. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  76. package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
  77. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  78. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  79. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  80. package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
  81. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  82. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  83. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  84. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  85. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  86. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  87. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  88. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  89. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  90. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  91. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  92. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  93. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  94. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  95. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  96. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  97. package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
  98. package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
  99. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  100. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
  101. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  102. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  103. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  104. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  105. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  106. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  107. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  108. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  109. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  110. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  111. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  112. package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
  113. package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
  114. package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
  115. package/plugins/trailhead/tests/mod.test.ts +14 -0
  116. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  117. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  118. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  119. package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
  120. package/plugins/trailhead/types/index.d.ts +103 -0
@@ -0,0 +1,606 @@
1
+ // Pure view logic of the map pane: parse what `gh` printed, group the rows, word a
2
+ // failure and an age, decide when a refresh may run. No engine calls here, so it is easy to test.
3
+ import { fit } from './work-state-view'
4
+ import type { BurndownHistory, BurndownIssue, DashboardRef, MapCounts, MapInfo, MapsRead, MapSummary, PaneTicket, TicketState, TicketType, UatParent } from '../../types'
5
+
6
+ export const TICKET_TYPES: readonly TicketType[] = ['decision', 'research', 'prototype', 'build', 'bug', 'task']
7
+
8
+ // `isCapped`: gh returned as many rows as it was asked for, so the list may be cut.
9
+ export type ParsedTickets = { tickets: readonly PaneTicket[]; unverifiedCount: number; isCapped: boolean }
10
+ export type TicketGroups = {
11
+ frontier: readonly PaneTicket[]
12
+ inProgress: readonly PaneTicket[]
13
+ uat: readonly PaneTicket[]
14
+ paused: readonly PaneTicket[]
15
+ blocked: readonly PaneTicket[]
16
+ }
17
+ export type Failure = { error: unknown } | { exitCode: number; stderr: string }
18
+ export type RefreshDecision = { run: true } | { run: false; waitMs: number }
19
+
20
+ // The most rows one `gh issue list` is asked for.
21
+ export const LIST_LIMIT = 200
22
+ // Same for the history read (`--state all`); newest first, so a cap loses the oldest tickets.
23
+ export const HISTORY_LIMIT = 5000
24
+
25
+ const LABEL_PREFIX = 'trailhead:'
26
+
27
+ const isPosInt = (v: unknown): v is number => Number.isInteger(v) && (v as number) > 0
28
+
29
+ const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
30
+
31
+ // Text from GitHub is untrusted and only drawn: one line, no control, zero-width or bidi characters.
32
+ const flatten = (text: string): string =>
33
+ text
34
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g, '')
35
+ .replace(/\s+/g, ' ')
36
+ .trim()
37
+
38
+ // The `.trailhead/active-map` marker: one line, `#<n>` or `<n>`.
39
+ export const parseActiveMap = (text: string): number | null => {
40
+ const m = /^#?(\d+)$/.exec(text.trim())
41
+ const n = m === null ? 0 : Number(m[1])
42
+ return isPosInt(n) ? n : null
43
+ }
44
+
45
+ const labelNames = (raw: unknown): string[] =>
46
+ Array.isArray(raw) ? raw.flatMap(l => (isRecord(l) && typeof l.name === 'string' ? [l.name] : [])) : []
47
+
48
+ const logins = (raw: unknown): string[] =>
49
+ Array.isArray(raw)
50
+ ? raw.flatMap(a => (isRecord(a) && typeof a.login === 'string' && a.login !== '' ? [flatten(a.login)] : []))
51
+ : []
52
+
53
+ const typeOf = (names: readonly string[]): TicketType | null => {
54
+ const found = names.find(n => TICKET_TYPES.some(t => n === `${LABEL_PREFIX}${t}`))
55
+ return found === undefined ? null : (found.slice(LABEL_PREFIX.length) as TicketType)
56
+ }
57
+
58
+ // A paused label wins over blocked and in-progress: the ticket shows once, under Paused.
59
+ const stateOf = (names: readonly string[], assignees: readonly string[]): TicketState =>
60
+ names.includes(`${LABEL_PREFIX}paused`)
61
+ ? 'paused'
62
+ : names.includes(`${LABEL_PREFIX}blocked`)
63
+ ? 'blocked'
64
+ : assignees.length > 0
65
+ ? 'in-progress'
66
+ : 'frontier'
67
+
68
+ // Only an https link is kept; anything else would be drawn as plain text.
69
+ const safeUrl = (raw: unknown): string => (typeof raw === 'string' && /^https:\/\//.test(raw) ? raw : '')
70
+
71
+ // `gh issue list --json number,title,labels,assignees,url`. Unverified rows are
72
+ // quarantined (counted, never listed); whiteboard rows do not belong to a map, and on the
73
+ // whiteboard (`isWhiteboard`) only they are kept.
74
+ export const parseTickets = (stdout: string, opts: { isWhiteboard?: boolean } = {}): ParsedTickets | null => {
75
+ let raw: unknown
76
+ try {
77
+ raw = JSON.parse(stdout)
78
+ } catch {
79
+ return null
80
+ }
81
+ if (!Array.isArray(raw)) return null
82
+ const rows = raw.filter(isRecord).filter(r => isPosInt(r.number))
83
+ const named = rows.map(r => ({ r, names: labelNames(r.labels) }))
84
+ const unverifiedCount = named.filter(({ names }) => names.includes(`${LABEL_PREFIX}unverified`)).length
85
+ const tickets = named
86
+ .filter(({ names }) => !names.includes(`${LABEL_PREFIX}unverified`))
87
+ .filter(({ names }) => names.includes(`${LABEL_PREFIX}whiteboard`) === (opts.isWhiteboard === true))
88
+ .map(({ r, names }): PaneTicket => {
89
+ const assignees = logins(r.assignees)
90
+ return {
91
+ number: r.number as number,
92
+ title: typeof r.title === 'string' ? flatten(r.title) : '',
93
+ type: typeOf(names),
94
+ state: stateOf(names, assignees),
95
+ assignees,
96
+ url: safeUrl(r.url),
97
+ isUat: names.includes(`${LABEL_PREFIX}uat`),
98
+ uatOf: null,
99
+ }
100
+ })
101
+ return { tickets, unverifiedCount, isCapped: raw.length >= LIST_LIMIT }
102
+ }
103
+
104
+ // `gh issue list --label trailhead:uat ... --json number,body`: ticket number -> the parent its first
105
+ // `UAT of:` line names, `[<name>](<url>/issues/<n>)` (or a bare `#<n>`, with an empty title). A row with
106
+ // no such line, or an unreadable one, is skipped; not a list is unreadable (null).
107
+ export const parseUatParents = (stdout: string): ReadonlyMap<number, UatParent> | null => {
108
+ let raw: unknown
109
+ try {
110
+ raw = JSON.parse(stdout)
111
+ } catch {
112
+ return null
113
+ }
114
+ if (!Array.isArray(raw)) return null
115
+ const parents = new Map<number, UatParent>()
116
+ for (const r of raw.filter(isRecord)) {
117
+ if (!isPosInt(r.number) || typeof r.body !== 'string') continue
118
+ const line = r.body.split(/\r?\n/).find(l => l.startsWith('UAT of:'))
119
+ if (line === undefined) continue
120
+ const pointer = line.slice('UAT of:'.length).trim()
121
+ const link = /^\[([^\]]*)\]\(https:\/\/[^)\s]*\/issues\/(\d+)[^)]*\)/.exec(pointer)
122
+ const bare = /^#(\d+)\b/.exec(pointer)
123
+ const found = link !== null ? { number: Number(link[2]), title: flatten(link[1] as string) } : bare !== null ? { number: Number(bare[1]), title: '' } : null
124
+ if (found !== null && isPosInt(found.number)) parents.set(r.number, found)
125
+ }
126
+ return parents
127
+ }
128
+
129
+ // The tickets with the parent of each UAT one; a missing read leaves them as they are.
130
+ export const withUatParents = (tickets: readonly PaneTicket[], parents: ReadonlyMap<number, UatParent> | null): readonly PaneTicket[] =>
131
+ parents === null ? tickets : tickets.map(t => (t.isUat ? { ...t, uatOf: parents.get(t.number) ?? null } : t))
132
+
133
+ // The dim line under a UAT row, `tests <parent title> #<n>`, cut to the pane width; null without a parent.
134
+ export const uatParentLine = (t: PaneTicket, columns: number): string | null => {
135
+ if (t.uatOf === null) return null
136
+ const suffix = ` #${t.uatOf.number}`
137
+ const prefix = 'tests'
138
+ if (t.uatOf.title === '') return fit(`${prefix}${suffix}`, columns)
139
+ const title = fit(t.uatOf.title, columns - prefix.length - 1 - suffix.length)
140
+ return `${prefix} ${title}${suffix}`
141
+ }
142
+
143
+ // Markdown emphasis, code ticks and links reduced to their text.
144
+ const stripMarkdown = (line: string): string =>
145
+ line
146
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
147
+ .replace(/(\*\*|__|\*|`)/g, '')
148
+ .replace(/(^|\s)_([^_]+)_(?=\s|$|[.,;:!?])/g, '$1$2')
149
+ .replace(/^>\s*/, '')
150
+
151
+ const destinationOf = (body: string): string | null => {
152
+ const lines = body.split(/\r?\n/)
153
+ const at = lines.findIndex(l => /^##\s+Destination\s*$/i.test(l))
154
+ if (at < 0) return null
155
+ const first = lines.slice(at + 1).find(l => l.trim() !== '')
156
+ if (first === undefined || /^#/.test(first.trim())) return null
157
+ const text = flatten(stripMarkdown(first))
158
+ return text === '' ? null : text
159
+ }
160
+
161
+ // `gh issue view <n> --json title,body,url`.
162
+ export const parseMap = (stdout: string, number: number): MapInfo | null => {
163
+ let raw: unknown
164
+ try {
165
+ raw = JSON.parse(stdout)
166
+ } catch {
167
+ return null
168
+ }
169
+ if (!isRecord(raw) || typeof raw.title !== 'string') return null
170
+ return {
171
+ number,
172
+ title: flatten(raw.title),
173
+ destination: typeof raw.body === 'string' ? destinationOf(raw.body) : null,
174
+ url: safeUrl(raw.url),
175
+ }
176
+ }
177
+
178
+ // `gh issue list --label trailhead:map --state open --json number,title,url`: the open maps by number.
179
+ // A row with no usable number is skipped; anything that is not a list is unreadable.
180
+ export const parseMaps = (stdout: string): readonly MapSummary[] | null => {
181
+ let raw: unknown
182
+ try {
183
+ raw = JSON.parse(stdout)
184
+ } catch {
185
+ return null
186
+ }
187
+ if (!Array.isArray(raw)) return null
188
+ return raw
189
+ .filter(isRecord)
190
+ .filter(r => isPosInt(r.number))
191
+ .map((r): MapSummary => ({ number: r.number as number, title: typeof r.title === 'string' ? flatten(r.title) : '', counts: null }))
192
+ .sort((a, b) => a.number - b.number)
193
+ }
194
+
195
+ const countsOf = (raw: unknown): MapCounts | null => {
196
+ if (!isRecord(raw)) return null
197
+ const { total, completed } = raw
198
+ if (!Number.isInteger(total) || !Number.isInteger(completed) || (total as number) < 0 || (completed as number) < 0) return null
199
+ return { open: Math.max(0, (total as number) - (completed as number)), total: total as number }
200
+ }
201
+
202
+ // A GraphQL `totalCount` as a count, else null.
203
+ const totalOf = (raw: unknown): number | null =>
204
+ isRecord(raw) && Number.isInteger(raw.totalCount) && (raw.totalCount as number) >= 0 ? (raw.totalCount as number) : null
205
+
206
+ // The whiteboard's open and total tickets; unusable (missing, negative, open above total) is unknown.
207
+ const whiteboardOf = (repo: Record<string, unknown>): MapCounts | null => {
208
+ const open = totalOf(repo.whiteboardOpen)
209
+ const total = totalOf(repo.whiteboardAll)
210
+ return open === null || total === null || open > total ? null : { open, total }
211
+ }
212
+
213
+ // The GraphQL reply of the maps read: the open maps with their sub-issue counts, same row rules as
214
+ // `parseMaps`, and the whiteboard's counts. A malformed summary keeps the row name-only; unusable
215
+ // whiteboard fields leave only `whiteboard` null. Not that shape, or any `errors` (even next to
216
+ // partial `data`), is unreadable: the caller falls back to the name-only list.
217
+ export const parseMapsRead = (stdout: string): MapsRead | null => {
218
+ let raw: unknown
219
+ try {
220
+ raw = JSON.parse(stdout)
221
+ } catch {
222
+ return null
223
+ }
224
+ if (!isRecord(raw) || 'errors' in raw || !isRecord(raw.data)) return null
225
+ const repo = raw.data.repository
226
+ const issues = isRecord(repo) ? repo.issues : null
227
+ const nodes = isRecord(issues) ? issues.nodes : null
228
+ if (!isRecord(repo) || !Array.isArray(nodes)) return null
229
+ const maps = nodes
230
+ .filter(isRecord)
231
+ .filter(r => isPosInt(r.number))
232
+ .map(
233
+ (r): MapSummary => ({
234
+ number: r.number as number,
235
+ title: typeof r.title === 'string' ? flatten(r.title) : '',
236
+ counts: countsOf(r.subIssuesSummary),
237
+ }),
238
+ )
239
+ .sort((a, b) => a.number - b.number)
240
+ return { maps, whiteboard: whiteboardOf(repo) }
241
+ }
242
+
243
+ // `gh issue list --label trailhead:dashboard --json number,url`: the pinned index, the lowest number
244
+ // when there are several. `null` when unreadable; `{ ref: null }` when the repo has none yet.
245
+ export const parseDashboard = (stdout: string): { ref: DashboardRef | null } | null => {
246
+ let raw: unknown
247
+ try {
248
+ raw = JSON.parse(stdout)
249
+ } catch {
250
+ return null
251
+ }
252
+ if (!Array.isArray(raw)) return null
253
+ const rows = raw.filter(isRecord).filter(r => isPosInt(r.number))
254
+ const first = rows.sort((a, b) => (a.number as number) - (b.number as number))[0]
255
+ return { ref: first === undefined ? null : { number: first.number as number, url: safeUrl(first.url) } }
256
+ }
257
+
258
+ // The map the pane draws: the one the person is viewing, else the active one.
259
+ export const viewTarget = (active: number | null, viewed: number | null): number | null => viewed ?? active
260
+
261
+ export type ViewChange = { activeChanged: boolean; maps: readonly MapSummary[] | null }
262
+
263
+ // The view goes back to following the active map when that map changed, or when the viewed
264
+ // map is gone from a freshly read open list (a missing list judges nothing).
265
+ export const nextViewed = (viewed: number | null, change: ViewChange): number | null => {
266
+ if (viewed === null || change.activeChanged) return null
267
+ return change.maps !== null && !change.maps.some(m => m.number === viewed) ? null : viewed
268
+ }
269
+
270
+ export type MapRowLayout = {
271
+ mark: string
272
+ label: string
273
+ aside: string | null
274
+ isActive: boolean
275
+ isViewed: boolean
276
+ // Every map but the viewed one: a press shows it (the active one: back to active).
277
+ isPressable: boolean
278
+ }
279
+ // `isDashboard`: the dashboard is shown, so no map is viewed and every row is pressable.
280
+ export type MapRowContext = { active: number | null; viewed: number | null; columns: number; isDashboard?: boolean }
281
+
282
+ const ACTIVE_MARK = '● '
283
+ const ACTIVE_LABEL = 'active'
284
+ const ASIDE_SEP = ' · '
285
+
286
+ // `<open> open / <total>`: the counts beside a map or the whiteboard.
287
+ export const countsLabel = (c: MapCounts): string => `${c.open} open / ${c.total}`
288
+
289
+ // What one row of the maps list draws, cut to the pane width (a pressable row is a Button).
290
+ export const mapRowLayout = (m: MapSummary, ctx: MapRowContext): MapRowLayout => {
291
+ const isActive = m.number === ctx.active
292
+ const isViewed = ctx.isDashboard !== true && m.number === viewTarget(ctx.active, ctx.viewed)
293
+ const mark = isActive ? ACTIVE_MARK : ' '
294
+ const parts = [m.counts === null ? null : countsLabel(m.counts), isActive ? ACTIVE_LABEL : null]
295
+ const shown = parts.filter((p): p is string => p !== null)
296
+ const aside = shown.length === 0 ? null : ` ${shown.join(ASIDE_SEP)}`
297
+ const prefix = `#${m.number}`
298
+ const isPressable = !isViewed
299
+ const room = ctx.columns - mark.length - (aside?.length ?? 0) - (isPressable ? BUTTON_CHROME : 0) - prefix.length - 1
300
+ const title = fit(m.title, room)
301
+ return { mark, label: title === '' ? prefix : `${prefix} ${title}`, aside, isActive, isViewed, isPressable }
302
+ }
303
+
304
+ export type WhiteboardRowLayout = { mark: string; label: string; aside: string | null }
305
+ const WHITEBOARD_LABEL = 'Whiteboard'
306
+
307
+ // The Whiteboard row above the maps list: a Button with its counts beside it, name-only when they are
308
+ // unknown or would not fit (the name is kept whole).
309
+ export const whiteboardRowLayout = (counts: MapCounts | null, columns: number): WhiteboardRowLayout => {
310
+ const mark = ' '
311
+ const aside = counts === null ? null : ` ${countsLabel(counts)}`
312
+ const fits = aside !== null && mark.length + BUTTON_CHROME + WHITEBOARD_LABEL.length + aside.length <= columns
313
+ return { mark, label: WHITEBOARD_LABEL, aside: fits ? aside : null }
314
+ }
315
+
316
+ // The whiteboard heading with its counts over the listed (trusted) tickets; bare before the first good read.
317
+ export const whiteboardHeading = (tickets: readonly PaneTicket[] | null, isCapped: boolean): string => {
318
+ if (tickets === null) return 'Whiteboard'
319
+ const open = isCapped ? `${LIST_LIMIT}+` : String(tickets.length)
320
+ return `Whiteboard · ${open} open · ${tickets.filter(t => t.assignees.length > 0).length} assigned`
321
+ }
322
+
323
+ const byNumber = (a: PaneTicket, b: PaneTicket) => a.number - b.number
324
+
325
+ // Paused wins, then a UAT ticket (whatever its state), then the state: each ticket shows once.
326
+ type Place = TicketState | 'uat'
327
+ const placeOf = (t: PaneTicket): Place => (t.state === 'paused' ? 'paused' : t.isUat ? 'uat' : t.state)
328
+
329
+ export const groupTickets = (tickets: readonly PaneTicket[]): TicketGroups => {
330
+ const of = (place: Place) => tickets.filter(t => placeOf(t) === place).sort(byNumber)
331
+ return { frontier: of('frontier'), inProgress: of('in-progress'), uat: of('uat'), paused: of('paused'), blocked: of('blocked') }
332
+ }
333
+
334
+ const errorText = (err: unknown): string => {
335
+ const e = err as { code?: unknown; message?: unknown } | null
336
+ return `${String(e?.code ?? '')} ${String(e?.message ?? err)}`
337
+ }
338
+
339
+ // One short line saying why `gh` gave nothing.
340
+ export const failureReason = (failure: Failure): string => {
341
+ if ('error' in failure) {
342
+ const text = errorText(failure.error)
343
+ if (/ENOENT|not found/i.test(text)) return 'gh not installed'
344
+ if (/timed? ?out/i.test(text)) return 'offline: cannot reach GitHub'
345
+ return 'gh failed (did not finish)'
346
+ }
347
+ if (/gh auth login|not logged in|authentication/i.test(failure.stderr)) return 'gh not authenticated (run gh auth login)'
348
+ if (/error connecting|could not resolve|dial tcp|no such host|network is unreachable|connection (refused|reset)|timed? ?out/i.test(failure.stderr)) {
349
+ return 'offline: cannot reach GitHub'
350
+ }
351
+ return `gh failed (exit ${failure.exitCode})`
352
+ }
353
+
354
+ // Run now, or wait the rest of the floor (a clock that moved back waits the whole floor at most).
355
+ export const refreshDecision = (now: number, lastStartedAt: number | null, minMs: number): RefreshDecision => {
356
+ if (lastStartedAt === null) return { run: true }
357
+ const waitMs = Math.min(minMs, minMs - (now - lastStartedAt))
358
+ return waitMs <= 0 ? { run: true } : { run: false, waitMs }
359
+ }
360
+
361
+ export const ageLabel = (ms: number): string => {
362
+ if (ms < 5_000) return 'just now'
363
+ if (ms < 60_000) return `${Math.floor(ms / 1_000)}s ago`
364
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`
365
+ return `${Math.floor(ms / 3_600_000)}h ago`
366
+ }
367
+
368
+ export type RowAside = { kind: 'work' | 'queued' | 'holder' | 'self'; text: string }
369
+ export type RowLayout = {
370
+ mark: string | null
371
+ label: string
372
+ type: string | null
373
+ aside: RowAside | null
374
+ isDim: boolean
375
+ isInPlay: boolean
376
+ }
377
+ export type RowContext = {
378
+ isInPlay: boolean
379
+ // True when the group holds the ticket in play: its rows share a two-cell mark column.
380
+ hasMarkColumn: boolean
381
+ pendingWork: number | null
382
+ columns: number
383
+ }
384
+
385
+ const MARK = '▸ '
386
+ const MARK_PAD = ' '
387
+ // A terminal Button draws `[ label ]`.
388
+ const BUTTON_CHROME = 4
389
+
390
+ // The dashboard header's button back to the map; the title before it gets the rest of the row.
391
+ export const MAP_LABEL = 'map'
392
+ export const headingRoom = (columns: number, label: string): number => Math.max(0, columns - label.length - BUTTON_CHROME - 1)
393
+
394
+ const REFRESH_LABEL = 'refresh'
395
+ const CLOSE_LABEL = 'close'
396
+ // The gap between the refresh button and the status, and the one kept before the close button.
397
+ const FOOTER_GAPS = 2
398
+
399
+ export type FooterStatus = { text: string; isInline: boolean }
400
+
401
+ // The footer status beside the `refresh` and `close` buttons when it fits the room they leave;
402
+ // otherwise on a row of its own under them, cut only to the pane width (a reason keeps its hint).
403
+ export const footerStatus = (status: string, columns: number): FooterStatus => {
404
+ const buttons = REFRESH_LABEL.length + CLOSE_LABEL.length + 2 * BUTTON_CHROME
405
+ const room = columns - buttons - FOOTER_GAPS
406
+ return Array.from(status).length <= room ? { text: status, isInline: true } : { text: fit(status, columns), isInline: false }
407
+ }
408
+
409
+ const SELF_ASIDE: RowAside = { kind: 'self', text: 'this session' }
410
+
411
+ // The ticket in play says `this session`; otherwise a frontier row has its button and the rest their holder.
412
+ const asideOf = (t: PaneTicket, pendingWork: number | null, isInPlay: boolean): RowAside | null => {
413
+ if (isInPlay) return SELF_ASIDE
414
+ if (t.state === 'frontier') return pendingWork === t.number ? { kind: 'queued', text: 'queued' } : { kind: 'work', text: 'work' }
415
+ return t.assignees.length === 0 ? null : { kind: 'holder', text: t.assignees.map(a => `@${a}`).join(' ') }
416
+ }
417
+
418
+ const asideWidth = (a: RowAside | null): number =>
419
+ a === null ? 0 : a.text.length + (a.kind === 'work' ? BUTTON_CHROME : 0) + 1
420
+
421
+ // What one row draws, cut to the pane width: mark column, `#n title`, type, right-hand part.
422
+ export const rowLayout = (t: PaneTicket, ctx: RowContext): RowLayout => {
423
+ const mark = ctx.isInPlay ? MARK : ctx.hasMarkColumn ? MARK_PAD : null
424
+ const aside = asideOf(t, ctx.pendingWork, ctx.isInPlay)
425
+ const type = t.type === null ? null : ` ${t.type}`
426
+ const prefix = `#${t.number}`
427
+ const room = ctx.columns - (mark?.length ?? 0) - asideWidth(aside) - (type?.length ?? 0) - prefix.length - 1
428
+ const title = fit(t.title, room)
429
+ return {
430
+ mark,
431
+ label: title === '' ? prefix : `${prefix} ${title}`,
432
+ type,
433
+ aside,
434
+ isDim: t.state === 'blocked',
435
+ isInPlay: ctx.isInPlay,
436
+ }
437
+ }
438
+
439
+ const REMOVED_LABELS = [`${LABEL_PREFIX}superseded`, `${LABEL_PREFIX}out-of-scope`]
440
+
441
+ const parseTime = (raw: unknown): number | null => {
442
+ if (typeof raw !== 'string') return null
443
+ const ms = Date.parse(raw)
444
+ return Number.isNaN(ms) ? null : ms
445
+ }
446
+
447
+ // gh prints an open issue's closedAt as the zero date: null, absent or anything at or before the epoch
448
+ // means open. Any other value that does not parse is unreadable (undefined), and the row is skipped.
449
+ const closedTime = (raw: unknown): number | null | undefined => {
450
+ if (raw === null || raw === undefined) return null
451
+ const ms = parseTime(raw)
452
+ if (ms === null) return undefined
453
+ return ms <= 0 ? null : ms
454
+ }
455
+
456
+ const historyIssue = (r: Record<string, unknown>): BurndownIssue[] => {
457
+ const names = labelNames(r.labels)
458
+ if (names.includes(`${LABEL_PREFIX}unverified`) || names.includes(`${LABEL_PREFIX}whiteboard`)) return []
459
+ const openedAt = parseTime(r.createdAt)
460
+ const closedAt = closedTime(r.closedAt)
461
+ if (openedAt === null || closedAt === undefined || (closedAt !== null && closedAt < openedAt)) return []
462
+ return [{ openedAt, closedAt, isRemoved: REMOVED_LABELS.some(l => names.includes(l)) }]
463
+ }
464
+
465
+ // `gh issue list --state all --json number,labels,createdAt,closedAt`.
466
+ export const parseHistory = (stdout: string): BurndownHistory | null => {
467
+ let raw: unknown
468
+ try {
469
+ raw = JSON.parse(stdout)
470
+ } catch {
471
+ return null
472
+ }
473
+ if (!Array.isArray(raw)) return null
474
+ return { issues: raw.filter(isRecord).flatMap(historyIssue), isCapped: raw.length >= HISTORY_LIMIT }
475
+ }
476
+
477
+ export type BurndownEnd = { at: number; label: string }
478
+
479
+ // Where the chart ends: now when its history was read by the last good fetch, else the time it was
480
+ // read, labelled with its age (a failed read keeps the last curve, it is not stretched to the present).
481
+ export const burndownEnd = (
482
+ d: { historyAt: number | null; fetchedAt: number | null; error: string | null },
483
+ now: number,
484
+ ): BurndownEnd =>
485
+ d.historyAt === null || (d.error === null && d.historyAt === d.fetchedAt)
486
+ ? { at: now, label: 'now' }
487
+ : { at: d.historyAt, label: ageLabel(now - d.historyAt) }
488
+
489
+ export type BurndownSeries = {
490
+ values: readonly number[]
491
+ open: number
492
+ peak: number
493
+ done: number
494
+ removed: number
495
+ startedAt: number
496
+ }
497
+
498
+ // Open tickets at `buckets` evenly spaced times from the first ticket to `now` (the last sample).
499
+ // The most open at once over every event, not only at the samples: same rule as a sample,
500
+ // a time counts every open and close at or before it.
501
+ const truePeak = (opened: readonly number[], closedTimes: readonly number[]): number => {
502
+ let peak = 0
503
+ let closes = 0
504
+ opened.forEach((t, n) => {
505
+ while (closes < closedTimes.length && (closedTimes[closes] as number) <= t) closes += 1
506
+ peak = Math.max(peak, n + 1 - closes)
507
+ })
508
+ return peak
509
+ }
510
+
511
+ export const burndownSeries = (history: BurndownHistory, now: number, buckets: number): BurndownSeries | null => {
512
+ if (history.issues.length === 0 || buckets <= 0) return null
513
+ const issues = history.issues.map(i => ({
514
+ ...i,
515
+ openedAt: Math.min(i.openedAt, now),
516
+ closedAt: i.closedAt === null ? null : Math.min(i.closedAt, now),
517
+ }))
518
+ const startedAt = Math.min(...issues.map(i => i.openedAt))
519
+ const timeAt = (n: number) => (n === buckets - 1 ? now : startedAt + ((now - startedAt) * n) / (buckets - 1))
520
+ // Sort once, then sweep: the samples only move forward, so each time is passed over once.
521
+ const opened = issues.map(i => i.openedAt).sort((a, b) => a - b)
522
+ const closedTimes = issues.flatMap(i => (i.closedAt === null ? [] : [i.closedAt])).sort((a, b) => a - b)
523
+ let opens = 0
524
+ let closes = 0
525
+ const values = Array.from({ length: buckets }, (_, n) => {
526
+ const t = timeAt(n)
527
+ while (opens < opened.length && (opened[opens] as number) <= t) opens += 1
528
+ while (closes < closedTimes.length && (closedTimes[closes] as number) <= t) closes += 1
529
+ return opens - closes
530
+ })
531
+ const closed = issues.filter(i => i.closedAt !== null)
532
+ return {
533
+ values,
534
+ open: values[values.length - 1] as number,
535
+ peak: Math.max(truePeak(opened, closedTimes), ...values),
536
+ done: closed.filter(i => !i.isRemoved).length,
537
+ removed: closed.filter(i => i.isRemoved).length,
538
+ startedAt,
539
+ }
540
+ }
541
+
542
+ const EIGHTHS = '▁▂▃▄▅▆▇█'
543
+
544
+ // Bar height in eighths of a cell over `levels`: zero stays zero, any other value is at least one.
545
+ const heightOf = (value: number, peak: number, levels: number): number =>
546
+ value <= 0 || peak <= 0 ? 0 : Math.min(levels, Math.max(1, Math.round((value / peak) * levels)))
547
+
548
+ // One row of eighth blocks; a zero is a space.
549
+ export const sparkline = (values: readonly number[], peak: number): string =>
550
+ values.map(v => (heightOf(v, peak, 8) === 0 ? ' ' : (EIGHTHS[heightOf(v, peak, 8) - 1] as string))).join('')
551
+
552
+ const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
553
+ const DEFAULT_COLOR = 0x01000000
554
+ const SPACE = 0x20
555
+ const FULL_BLOCK = 0x2588
556
+ const LOWER_BLOCK_BASE = 0x2580
557
+
558
+ // Padded base64 written here: the mod runtime is not promised Buffer, btoa or toBase64.
559
+ const base64 = (bytes: Uint8Array): string => {
560
+ const chars: string[] = []
561
+ for (let i = 0; i < bytes.length; i += 3) {
562
+ const b0 = bytes[i] as number
563
+ const b1 = bytes[i + 1]
564
+ const b2 = bytes[i + 2]
565
+ const n = (b0 << 16) | ((b1 ?? 0) << 8) | (b2 ?? 0)
566
+ chars.push(BASE64[(n >> 18) & 63] as string, BASE64[(n >> 12) & 63] as string)
567
+ chars.push(b1 === undefined ? '=' : (BASE64[(n >> 6) & 63] as string), b2 === undefined ? '=' : (BASE64[n & 63] as string))
568
+ }
569
+ return chars.join('')
570
+ }
571
+
572
+ // The glyph of one cell: `fromBottom` rows below it are full, `eighths` is the bar height.
573
+ const glyphAt = (eighths: number, fromBottom: number): number => {
574
+ const full = Math.floor(eighths / 8)
575
+ if (fromBottom < full) return FULL_BLOCK
576
+ return fromBottom === full && eighths % 8 > 0 ? LOWER_BLOCK_BASE + (eighths % 8) : SPACE
577
+ }
578
+
579
+ // The Raster `cells` prop: row-major (top first) little-endian u32 triplets [codePoint, fg, bg].
580
+ export const rasterCells = (values: readonly number[], peak: number, rows: number, fg: number): string => {
581
+ const columns = values.length
582
+ const heights = values.map(v => heightOf(v, peak, rows * 8))
583
+ const bytes = new Uint8Array(columns * rows * 12)
584
+ const view = new DataView(bytes.buffer)
585
+ for (let row = 0; row < rows; row += 1) {
586
+ heights.forEach((h, col) => {
587
+ const at = (row * columns + col) * 12
588
+ view.setUint32(at, glyphAt(h, rows - 1 - row), true)
589
+ view.setUint32(at + 4, fg, true)
590
+ view.setUint32(at + 8, DEFAULT_COLOR, true)
591
+ })
592
+ }
593
+ return base64(bytes)
594
+ }
595
+
596
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
597
+ const DAY_MS = 86_400_000
598
+ const pad2 = (n: number) => String(n).padStart(2, '0')
599
+
600
+ // Left axis label, in UTC so it is the same everywhere: `Sep 18`, or `today 09:05` under a day.
601
+ export const axisStart = (startedAt: number, now: number): string => {
602
+ const d = new Date(startedAt)
603
+ return now - startedAt < DAY_MS
604
+ ? `today ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`
605
+ : `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`
606
+ }