@marcomigozzi/trailhead 0.14.0 → 0.16.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 (156) hide show
  1. package/README.it.md +158 -34
  2. package/README.md +147 -33
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/base-preflight-docs.test.js +65 -0
  12. package/bin/execute-split-docs.test.js +146 -0
  13. package/bin/grilling-holistic-review-docs.test.js +90 -0
  14. package/bin/isolation-answer-docs.test.js +41 -0
  15. package/bin/isolation-copy-docs.test.js +28 -0
  16. package/bin/lib/codex-projection.js +6 -6
  17. package/bin/lib/codex-projection.test.js +43 -2
  18. package/bin/needs-you-docs.test.js +41 -0
  19. package/bin/pick-stress-test-docs.test.js +105 -0
  20. package/bin/report-status-docs.test.js +195 -0
  21. package/bin/resolve-helpers-docs.test.js +36 -0
  22. package/bin/scope-confirm-skip-docs.test.js +70 -0
  23. package/bin/trailhead.js +6 -6
  24. package/bin/trailhead.test.js +355 -16
  25. package/bin/uat-setup-docs.test.js +65 -0
  26. package/package.json +1 -1
  27. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  28. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  29. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  30. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  31. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  32. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  33. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  34. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  35. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  36. package/plugins/trailhead/commands/auto.md +2 -2
  37. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  38. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  39. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  40. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  41. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  42. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  43. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  44. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  45. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  46. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  47. package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
  48. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  49. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  50. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  51. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  52. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  53. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  54. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  55. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  56. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  57. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  58. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  59. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  60. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  61. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  62. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  63. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  64. package/plugins/trailhead/hooks/mod/README.md +209 -36
  65. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  66. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  67. package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
  68. package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
  69. package/plugins/trailhead/hooks/mod/register.tsx +312 -40
  70. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  71. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  72. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  73. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  74. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  75. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  76. package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
  77. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  79. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  80. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  81. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  83. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  84. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  85. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  86. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  87. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  88. package/plugins/trailhead/skills/_shared/choices.md +11 -4
  89. package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
  90. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  91. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  92. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  93. package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
  94. package/plugins/trailhead/skills/_shared/substrate.md +9 -4
  95. package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
  96. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  103. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  104. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  105. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  111. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  112. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  113. package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
  114. package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
  115. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  116. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  117. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
  118. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  119. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  120. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  121. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  122. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  123. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  125. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  126. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  127. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  128. package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  135. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  136. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  137. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  138. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  139. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  140. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  141. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  142. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  143. package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
  144. package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
  145. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  146. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  147. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  148. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  149. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  150. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  151. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  152. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  153. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  154. package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
  155. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  156. package/plugins/trailhead/types/index.d.ts +62 -3
@@ -1,15 +1,18 @@
1
1
  // Pure view logic of the map pane: parse what `gh` printed, group the rows, word a
2
2
  // failure and an age, decide when a refresh may run. No engine calls here, so it is easy to test.
3
3
  import { fit } from './work-state-view'
4
- import type { BurndownHistory, BurndownIssue, MapInfo, MapSummary, PaneTicket, TicketState, TicketType } from '../../types'
4
+ import type { BurndownHistory, BurndownIssue, DashboardRef, MapCounts, MapInfo, MapsRead, MapSummary, OpenTotal, PaneTicket, TicketState, TicketType, UatParent } from '../../types'
5
5
 
6
6
  export const TICKET_TYPES: readonly TicketType[] = ['decision', 'research', 'prototype', 'build', 'bug', 'task']
7
7
 
8
8
  // `isCapped`: gh returned as many rows as it was asked for, so the list may be cut.
9
9
  export type ParsedTickets = { tickets: readonly PaneTicket[]; unverifiedCount: number; isCapped: boolean }
10
10
  export type TicketGroups = {
11
+ needsYou: readonly PaneTicket[]
11
12
  frontier: readonly PaneTicket[]
12
13
  inProgress: readonly PaneTicket[]
14
+ uat: readonly PaneTicket[]
15
+ paused: readonly PaneTicket[]
13
16
  blocked: readonly PaneTicket[]
14
17
  }
15
18
  export type Failure = { error: unknown } | { exitCode: number; stderr: string }
@@ -53,27 +56,29 @@ const typeOf = (names: readonly string[]): TicketType | null => {
53
56
  return found === undefined ? null : (found.slice(LABEL_PREFIX.length) as TicketType)
54
57
  }
55
58
 
59
+ // A paused label wins over blocked and in-progress: the ticket shows once, under Paused.
56
60
  const stateOf = (names: readonly string[], assignees: readonly string[]): TicketState =>
57
- names.includes(`${LABEL_PREFIX}blocked`) ? 'blocked' : assignees.length > 0 ? 'in-progress' : 'frontier'
61
+ names.includes(`${LABEL_PREFIX}paused`)
62
+ ? 'paused'
63
+ : names.includes(`${LABEL_PREFIX}blocked`)
64
+ ? 'blocked'
65
+ : assignees.length > 0
66
+ ? 'in-progress'
67
+ : 'frontier'
58
68
 
59
69
  // Only an https link is kept; anything else would be drawn as plain text.
60
70
  const safeUrl = (raw: unknown): string => (typeof raw === 'string' && /^https:\/\//.test(raw) ? raw : '')
61
71
 
62
- // `gh issue list --json number,title,labels,assignees,url`. Unverified rows are
63
- // quarantined (counted, never listed); whiteboard rows do not belong to a map.
64
- export const parseTickets = (stdout: string): ParsedTickets | null => {
65
- let raw: unknown
66
- try {
67
- raw = JSON.parse(stdout)
68
- } catch {
69
- return null
70
- }
71
- if (!Array.isArray(raw)) return null
72
+ // The rows of an open-ticket list (the `gh issue list --json number,title,labels,assignees,url` shape).
73
+ // Unverified rows are quarantined (counted, never listed); whiteboard rows do not belong to a map, and on the
74
+ // whiteboard (`isWhiteboard`) only they are kept. A list that reached LIST_LIMIT rows is capped.
75
+ export const ticketsFromRows = (raw: readonly unknown[], opts: { isWhiteboard?: boolean } = {}): ParsedTickets => {
72
76
  const rows = raw.filter(isRecord).filter(r => isPosInt(r.number))
73
77
  const named = rows.map(r => ({ r, names: labelNames(r.labels) }))
74
78
  const unverifiedCount = named.filter(({ names }) => names.includes(`${LABEL_PREFIX}unverified`)).length
75
79
  const tickets = named
76
- .filter(({ names }) => !names.includes(`${LABEL_PREFIX}unverified`) && !names.includes(`${LABEL_PREFIX}whiteboard`))
80
+ .filter(({ names }) => !names.includes(`${LABEL_PREFIX}unverified`))
81
+ .filter(({ names }) => names.includes(`${LABEL_PREFIX}whiteboard`) === (opts.isWhiteboard === true))
77
82
  .map(({ r, names }): PaneTicket => {
78
83
  const assignees = logins(r.assignees)
79
84
  return {
@@ -83,11 +88,100 @@ export const parseTickets = (stdout: string): ParsedTickets | null => {
83
88
  state: stateOf(names, assignees),
84
89
  assignees,
85
90
  url: safeUrl(r.url),
91
+ isUat: names.includes(`${LABEL_PREFIX}uat`),
92
+ isNeedsYou: names.includes(`${LABEL_PREFIX}needs-you`),
93
+ uatOf: null,
86
94
  }
87
95
  })
88
96
  return { tickets, unverifiedCount, isCapped: raw.length >= LIST_LIMIT }
89
97
  }
90
98
 
99
+ // `gh issue list --json ...` output as a ticket list; not a list is unreadable (null).
100
+ export const parseTickets = (stdout: string, opts: { isWhiteboard?: boolean } = {}): ParsedTickets | null => {
101
+ let raw: unknown
102
+ try {
103
+ raw = JSON.parse(stdout)
104
+ } catch {
105
+ return null
106
+ }
107
+ return Array.isArray(raw) ? ticketsFromRows(raw, opts) : null
108
+ }
109
+
110
+ // The rows that carry `name`: a GraphQL `labels:` filter is any-of, so an AND of two labels is done here.
111
+ export const withLabel = (rows: readonly unknown[], name: string): unknown[] =>
112
+ rows.filter(r => isRecord(r) && labelNames(r.labels).includes(name))
113
+
114
+ export type IssuesPage = { rows: unknown[]; hasNextPage: boolean; endCursor: string | null }
115
+
116
+ // One page of the GraphQL open-ticket read (`repository.issues(...){nodes{...}pageInfo{...}}`), its nodes
117
+ // flattened to the rows `gh issue list --json` prints so one parser serves both (`body` rides along only when
118
+ // the node has a string one, so the ticket rows keep their shape). Any `errors` (even beside
119
+ // partial `data`), a missing page info or a next page without a cursor is unreadable: the caller decides (`readOpen` falls back to
120
+ // `gh issue list`, `readUat` fails closed to null).
121
+ export const parseIssuesPage = (stdout: string): IssuesPage | null => {
122
+ let raw: unknown
123
+ try {
124
+ raw = JSON.parse(stdout)
125
+ } catch {
126
+ return null
127
+ }
128
+ if (!isRecord(raw) || 'errors' in raw || !isRecord(raw.data) || !isRecord(raw.data.repository)) return null
129
+ const issues = raw.data.repository.issues
130
+ if (!isRecord(issues) || !Array.isArray(issues.nodes) || !isRecord(issues.pageInfo)) return null
131
+ const { hasNextPage, endCursor } = issues.pageInfo
132
+ if (typeof hasNextPage !== 'boolean') return null
133
+ if (hasNextPage && (typeof endCursor !== 'string' || endCursor === '')) return null
134
+ const inner = (v: unknown) => (isRecord(v) && Array.isArray(v.nodes) ? v.nodes : [])
135
+ const rows = issues.nodes.filter(isRecord).map(n => ({
136
+ number: n.number,
137
+ title: n.title,
138
+ url: n.url,
139
+ labels: inner(n.labels),
140
+ assignees: inner(n.assignees),
141
+ ...(typeof n.body === 'string' ? { body: n.body } : {}),
142
+ }))
143
+ return { rows, hasNextPage, endCursor: hasNextPage ? (endCursor as string) : null }
144
+ }
145
+
146
+ // The open `trailhead:uat` rows of the GraphQL read, as JSON (`number` and `body`): ticket number -> the parent its first
147
+ // `UAT of:` line names, `[<name>](<url>/issues/<n>)` (or a bare `#<n>`, with an empty title). A row with
148
+ // no such line, or an unreadable one, is skipped; not a list is unreadable (null).
149
+ export const parseUatParents = (stdout: string): ReadonlyMap<number, UatParent> | null => {
150
+ let raw: unknown
151
+ try {
152
+ raw = JSON.parse(stdout)
153
+ } catch {
154
+ return null
155
+ }
156
+ if (!Array.isArray(raw)) return null
157
+ const parents = new Map<number, UatParent>()
158
+ for (const r of raw.filter(isRecord)) {
159
+ if (!isPosInt(r.number) || typeof r.body !== 'string') continue
160
+ const line = r.body.split(/\r?\n/).find(l => l.startsWith('UAT of:'))
161
+ if (line === undefined) continue
162
+ const pointer = line.slice('UAT of:'.length).trim()
163
+ const link = /^\[([^\]]*)\]\(https:\/\/[^)\s]*\/issues\/(\d+)[^)]*\)/.exec(pointer)
164
+ const bare = /^#(\d+)\b/.exec(pointer)
165
+ const found = link !== null ? { number: Number(link[2]), title: flatten(link[1] as string) } : bare !== null ? { number: Number(bare[1]), title: '' } : null
166
+ if (found !== null && isPosInt(found.number)) parents.set(r.number, found)
167
+ }
168
+ return parents
169
+ }
170
+
171
+ // The tickets with the parent of each UAT one; a missing read leaves them as they are.
172
+ export const withUatParents = (tickets: readonly PaneTicket[], parents: ReadonlyMap<number, UatParent> | null): readonly PaneTicket[] =>
173
+ parents === null ? tickets : tickets.map(t => (t.isUat ? { ...t, uatOf: parents.get(t.number) ?? null } : t))
174
+
175
+ // The dim line under a UAT row, `tests <parent title> #<n>`, cut to the pane width; null without a parent.
176
+ export const uatParentLine = (t: PaneTicket, columns: number): string | null => {
177
+ if (t.uatOf === null) return null
178
+ const suffix = ` #${t.uatOf.number}`
179
+ const prefix = 'tests'
180
+ if (t.uatOf.title === '') return fit(`${prefix}${suffix}`, columns)
181
+ const title = fit(t.uatOf.title, columns - prefix.length - 1 - suffix.length)
182
+ return `${prefix} ${title}${suffix}`
183
+ }
184
+
91
185
  // Markdown emphasis, code ticks and links reduced to their text.
92
186
  const stripMarkdown = (line: string): string =>
93
187
  line
@@ -136,8 +230,119 @@ export const parseMaps = (stdout: string): readonly MapSummary[] | null => {
136
230
  return raw
137
231
  .filter(isRecord)
138
232
  .filter(r => isPosInt(r.number))
139
- .map((r): MapSummary => ({ number: r.number as number, title: typeof r.title === 'string' ? flatten(r.title) : '' }))
233
+ .map((r): MapSummary => ({ number: r.number as number, title: typeof r.title === 'string' ? flatten(r.title) : '', counts: null }))
234
+ .sort((a, b) => a.number - b.number)
235
+ }
236
+
237
+ const countsOf = (raw: unknown): MapCounts | null => {
238
+ if (!isRecord(raw)) return null
239
+ const { total, completed } = raw
240
+ if (!Number.isInteger(total) || !Number.isInteger(completed) || (total as number) < 0 || (completed as number) < 0) return null
241
+ return { open: Math.max(0, (total as number) - (completed as number)), total: total as number }
242
+ }
243
+
244
+ // A GraphQL `totalCount` as a count, else null.
245
+ const totalOf = (raw: unknown): number | null =>
246
+ isRecord(raw) && Number.isInteger(raw.totalCount) && (raw.totalCount as number) >= 0 ? (raw.totalCount as number) : null
247
+
248
+ // The whiteboard's open and total tickets; unusable (missing, negative, open above total) is unknown.
249
+ const whiteboardOf = (repo: Record<string, unknown>): MapCounts | null => {
250
+ const open = totalOf(repo.whiteboardOpen)
251
+ const total = totalOf(repo.whiteboardAll)
252
+ return open === null || total === null || open > total ? null : { open, total }
253
+ }
254
+
255
+ // The GraphQL reply of the maps read: the open maps with their sub-issue counts, same row rules as
256
+ // `parseMaps`, and the whiteboard's counts. A malformed summary keeps the row name-only; unusable
257
+ // whiteboard fields leave only `whiteboard` null. Not that shape, or any `errors` (even next to
258
+ // partial `data`), is unreadable: the caller falls back to the name-only list.
259
+ export const parseMapsRead = (stdout: string): MapsRead | null => {
260
+ let raw: unknown
261
+ try {
262
+ raw = JSON.parse(stdout)
263
+ } catch {
264
+ return null
265
+ }
266
+ if (!isRecord(raw) || 'errors' in raw || !isRecord(raw.data)) return null
267
+ const repo = raw.data.repository
268
+ const issues = isRecord(repo) ? repo.issues : null
269
+ const nodes = isRecord(issues) ? issues.nodes : null
270
+ if (!isRecord(repo) || !Array.isArray(nodes)) return null
271
+ const maps = nodes
272
+ .filter(isRecord)
273
+ .filter(r => isPosInt(r.number))
274
+ .map(
275
+ (r): MapSummary => ({
276
+ number: r.number as number,
277
+ title: typeof r.title === 'string' ? flatten(r.title) : '',
278
+ counts: countsOf(r.subIssuesSummary),
279
+ }),
280
+ )
140
281
  .sort((a, b) => a.number - b.number)
282
+ return { maps, whiteboard: whiteboardOf(repo), isMapsCapped: isCappedBy(issues, nodes.length) }
283
+ }
284
+
285
+ // More open maps exist than the read returned (`totalCount` above the nodes read); without a usable
286
+ // `totalCount` a read that filled MAPS_READ_LIMIT is taken as cut.
287
+ const isCappedBy = (issues: Record<string, unknown>, read: number): boolean => {
288
+ const count = totalOf(issues)
289
+ return count === null ? read >= MAPS_READ_LIMIT : count > read
290
+ }
291
+
292
+ // The most open maps one maps read asks for.
293
+ export const MAPS_READ_LIMIT = 100
294
+
295
+ // One query counting the open tickets of every open map and of the whiteboard: the dashboard's own scopes
296
+ // (a ticket carries one map label or the whiteboard label, never both, so nothing counts twice).
297
+ export const openTotalQuery = (maps: readonly number[]): string => {
298
+ const scope = (alias: string, label: string) => `${alias}:issues(states:OPEN,labels:["${label}"]){totalCount}`
299
+ const fields = [...maps.map(n => scope(`m${n}`, `${LABEL_PREFIX}map-${n}`)), scope('wb', `${LABEL_PREFIX}whiteboard`)]
300
+ return `query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){${fields.join('')}}}`
301
+ }
302
+
303
+ // The sum of every alias of `openTotalQuery`; any `errors`, a missing alias or an unusable count is
304
+ // unreadable (null), never a partial sum shown as exact.
305
+ export const parseOpenTotal = (stdout: string, maps: readonly number[]): number | null => {
306
+ let raw: unknown
307
+ try {
308
+ raw = JSON.parse(stdout)
309
+ } catch {
310
+ return null
311
+ }
312
+ if (!isRecord(raw) || 'errors' in raw || !isRecord(raw.data) || !isRecord(raw.data.repository)) return null
313
+ const repo = raw.data.repository
314
+ const counts = [...maps.map(n => totalOf(repo[`m${n}`])), totalOf(repo.wb)]
315
+ return counts.some(c => c === null) ? null : counts.reduce((sum: number, c) => sum + (c as number), 0)
316
+ }
317
+
318
+ // The last good total, kept through a failed count read only while it was computed over exactly these open
319
+ // maps (and the same cut of the maps read); a changed map set hides it rather than show it stale.
320
+ export const keepOpenTotal = (kept: OpenTotal | null, maps: readonly number[], isMapsCapped: boolean): OpenTotal | null =>
321
+ kept !== null && kept.isMapsCapped === isMapsCapped && kept.maps.length === maps.length && kept.maps.every((n, i) => n === maps[i]) ? kept : null
322
+
323
+ // The line of the total, long form first, the short one when that does not fit, null when unknown or when
324
+ // not even the short one fits. A truncated maps read is a lower bound, worded as one.
325
+ export const openTotalLabel = (total: number | null, isCapped: boolean, columns: number): string | null => {
326
+ if (total === null) return null
327
+ const long = `${isCapped ? 'at least ' : ''}${total} open across all maps + whiteboard`
328
+ if (long.length <= columns) return long
329
+ const short = `${isCapped ? '≥' : ''}${total} open in total`
330
+ return short.length <= columns ? short : null
331
+ }
332
+
333
+ // `gh issue list --label trailhead:dashboard --json number,url`: the pinned index, the lowest number
334
+ // when there are several. `null` when unreadable; `{ ref: null }` when the repo has none yet.
335
+ export const parseDashboard = (stdout: string): { ref: DashboardRef | null } | null => {
336
+ let raw: unknown
337
+ try {
338
+ raw = JSON.parse(stdout)
339
+ } catch {
340
+ return null
341
+ }
342
+ if (!Array.isArray(raw)) return null
343
+ const rows = raw.filter(isRecord).filter(r => isPosInt(r.number))
344
+ const first = rows.sort((a, b) => (a.number as number) - (b.number as number))[0]
345
+ return { ref: first === undefined ? null : { number: first.number as number, url: safeUrl(first.url) } }
141
346
  }
142
347
 
143
348
  // The map the pane draws: the one the person is viewing, else the active one.
@@ -161,17 +366,24 @@ export type MapRowLayout = {
161
366
  // Every map but the viewed one: a press shows it (the active one: back to active).
162
367
  isPressable: boolean
163
368
  }
164
- export type MapRowContext = { active: number | null; viewed: number | null; columns: number }
369
+ // `isDashboard`: the dashboard is shown, so no map is viewed and every row is pressable.
370
+ export type MapRowContext = { active: number | null; viewed: number | null; columns: number; isDashboard?: boolean }
165
371
 
166
372
  const ACTIVE_MARK = '● '
167
- const ACTIVE_ASIDE = ' active'
373
+ const ACTIVE_LABEL = 'active'
374
+ const ASIDE_SEP = ' · '
375
+
376
+ // `<open> open / <total>`: the counts beside a map or the whiteboard.
377
+ export const countsLabel = (c: MapCounts): string => `${c.open} open / ${c.total}`
168
378
 
169
379
  // What one row of the maps list draws, cut to the pane width (a pressable row is a Button).
170
380
  export const mapRowLayout = (m: MapSummary, ctx: MapRowContext): MapRowLayout => {
171
381
  const isActive = m.number === ctx.active
172
- const isViewed = m.number === viewTarget(ctx.active, ctx.viewed)
382
+ const isViewed = ctx.isDashboard !== true && m.number === viewTarget(ctx.active, ctx.viewed)
173
383
  const mark = isActive ? ACTIVE_MARK : ' '
174
- const aside = isActive ? ACTIVE_ASIDE : null
384
+ const parts = [m.counts === null ? null : countsLabel(m.counts), isActive ? ACTIVE_LABEL : null]
385
+ const shown = parts.filter((p): p is string => p !== null)
386
+ const aside = shown.length === 0 ? null : ` ${shown.join(ASIDE_SEP)}`
175
387
  const prefix = `#${m.number}`
176
388
  const isPressable = !isViewed
177
389
  const room = ctx.columns - mark.length - (aside?.length ?? 0) - (isPressable ? BUTTON_CHROME : 0) - prefix.length - 1
@@ -179,11 +391,34 @@ export const mapRowLayout = (m: MapSummary, ctx: MapRowContext): MapRowLayout =>
179
391
  return { mark, label: title === '' ? prefix : `${prefix} ${title}`, aside, isActive, isViewed, isPressable }
180
392
  }
181
393
 
394
+ export type WhiteboardRowLayout = { mark: string; label: string; aside: string | null }
395
+ const WHITEBOARD_LABEL = 'Whiteboard'
396
+
397
+ // The Whiteboard row above the maps list: a Button with its counts beside it, name-only when they are
398
+ // unknown or would not fit (the name is kept whole).
399
+ export const whiteboardRowLayout = (counts: MapCounts | null, columns: number): WhiteboardRowLayout => {
400
+ const mark = ' '
401
+ const aside = counts === null ? null : ` ${countsLabel(counts)}`
402
+ const fits = aside !== null && mark.length + BUTTON_CHROME + WHITEBOARD_LABEL.length + aside.length <= columns
403
+ return { mark, label: WHITEBOARD_LABEL, aside: fits ? aside : null }
404
+ }
405
+
406
+ // The whiteboard heading with its counts over the listed (trusted) tickets; bare before the first good read.
407
+ export const whiteboardHeading = (tickets: readonly PaneTicket[] | null, isCapped: boolean): string => {
408
+ if (tickets === null) return 'Whiteboard'
409
+ const open = isCapped ? `${LIST_LIMIT}+` : String(tickets.length)
410
+ return `Whiteboard · ${open} open · ${tickets.filter(t => t.assignees.length > 0).length} assigned`
411
+ }
412
+
182
413
  const byNumber = (a: PaneTicket, b: PaneTicket) => a.number - b.number
183
414
 
415
+ // Needs-you wins, then paused, then a UAT ticket (whatever its state), then the state: each ticket shows once.
416
+ type Place = TicketState | 'uat' | 'needs-you'
417
+ const placeOf = (t: PaneTicket): Place => (t.isNeedsYou ? 'needs-you' : t.state === 'paused' ? 'paused' : t.isUat ? 'uat' : t.state)
418
+
184
419
  export const groupTickets = (tickets: readonly PaneTicket[]): TicketGroups => {
185
- const of = (state: TicketState) => tickets.filter(t => t.state === state).sort(byNumber)
186
- return { frontier: of('frontier'), inProgress: of('in-progress'), blocked: of('blocked') }
420
+ const of = (place: Place) => tickets.filter(t => placeOf(t) === place).sort(byNumber)
421
+ return { needsYou: of('needs-you'), frontier: of('frontier'), inProgress: of('in-progress'), uat: of('uat'), paused: of('paused'), blocked: of('blocked') }
187
422
  }
188
423
 
189
424
  const errorText = (err: unknown): string => {
@@ -213,6 +448,10 @@ export const refreshDecision = (now: number, lastStartedAt: number | null, minMs
213
448
  return waitMs <= 0 ? { run: true } : { run: false, waitMs }
214
449
  }
215
450
 
451
+ // A host timer dies silently (a refused `clock.after` never fires, a refused `every` period ends the
452
+ // interval): one whose due time is past by more than the grace is held dead and started again.
453
+ export const isTimerLate = (now: number, dueAt: number, graceMs: number): boolean => now - dueAt > graceMs
454
+
216
455
  export const ageLabel = (ms: number): string => {
217
456
  if (ms < 5_000) return 'just now'
218
457
  if (ms < 60_000) return `${Math.floor(ms / 1_000)}s ago`
@@ -220,7 +459,7 @@ export const ageLabel = (ms: number): string => {
220
459
  return `${Math.floor(ms / 3_600_000)}h ago`
221
460
  }
222
461
 
223
- export type RowAside = { kind: 'work' | 'queued' | 'holder'; text: string }
462
+ export type RowAside = { kind: 'work' | 'resume' | 'queued' | 'starting' | 'holder' | 'self'; text: string }
224
463
  export type RowLayout = {
225
464
  mark: string | null
226
465
  label: string
@@ -234,6 +473,8 @@ export type RowContext = {
234
473
  // True when the group holds the ticket in play: its rows share a two-cell mark column.
235
474
  hasMarkColumn: boolean
236
475
  pendingWork: number | null
476
+ // Whether the pending press waits for the turn to end (`queued`) or has been sent (`starting`).
477
+ pendingPhase: 'queued' | 'starting' | null
237
478
  columns: number
238
479
  }
239
480
 
@@ -242,6 +483,10 @@ const MARK_PAD = ' '
242
483
  // A terminal Button draws `[ label ]`.
243
484
  const BUTTON_CHROME = 4
244
485
 
486
+ // The dashboard header's button back to the map; the title before it gets the rest of the row.
487
+ export const MAP_LABEL = 'map'
488
+ export const headingRoom = (columns: number, label: string): number => Math.max(0, columns - label.length - BUTTON_CHROME - 1)
489
+
245
490
  const REFRESH_LABEL = 'refresh'
246
491
  const CLOSE_LABEL = 'close'
247
492
  // The gap between the refresh button and the status, and the one kept before the close button.
@@ -257,18 +502,51 @@ export const footerStatus = (status: string, columns: number): FooterStatus => {
257
502
  return Array.from(status).length <= room ? { text: status, isInline: true } : { text: fit(status, columns), isInline: false }
258
503
  }
259
504
 
260
- const asideOf = (t: PaneTicket, pendingWork: number | null): RowAside | null => {
261
- if (t.state === 'frontier') return pendingWork === t.number ? { kind: 'queued', text: 'queued' } : { kind: 'work', text: 'work' }
262
- return t.assignees.length === 0 ? null : { kind: 'holder', text: t.assignees.map(a => `@${a}`).join(' ') }
505
+ const SELF_ASIDE: RowAside = { kind: 'self', text: 'this session' }
506
+
507
+ type Pending = { number: number | null; phase: 'queued' | 'starting' | null }
508
+ // Only a press known to wait for the turn to end is offered a cancel; one sent, or not yet classed, is not.
509
+ const pendingAside = (phase: Pending['phase']): RowAside =>
510
+ phase === 'queued' ? { kind: 'queued', text: 'queued' } : { kind: 'starting', text: 'starting' }
511
+
512
+ const holderOf = (t: PaneTicket): RowAside => ({ kind: 'holder', text: t.assignees.map(a => `@${a}`).join(' ') })
513
+
514
+ // A needs-you row: any holder first (a `work` press would steal the claim), then nothing when blocked,
515
+ // else `work` (never `resume`: the claim clears the mark, resume would not).
516
+ const needsYouAside = (t: PaneTicket, pending: Pending): RowAside | null => {
517
+ if (t.assignees.length > 0) return holderOf(t)
518
+ if (t.state === 'blocked') return null
519
+ return pending.number === t.number ? pendingAside(pending.phase) : { kind: 'work', text: 'work' }
520
+ }
521
+
522
+ // The ticket in play says `this session`; a needs-you row follows its own rule; otherwise a frontier row has
523
+ // a `work` button, a paused one a `resume` button (the verb checks the claim), and the rest their holder.
524
+ const asideOf = (t: PaneTicket, pending: Pending, isInPlay: boolean): RowAside | null => {
525
+ if (isInPlay) return SELF_ASIDE
526
+ if (t.isNeedsYou) return needsYouAside(t, pending)
527
+ if (t.state === 'frontier' || t.state === 'paused') {
528
+ if (pending.number === t.number) return pendingAside(pending.phase)
529
+ return t.state === 'frontier' ? { kind: 'work', text: 'work' } : { kind: 'resume', text: 'resume' }
530
+ }
531
+ return t.assignees.length === 0 ? null : holderOf(t)
263
532
  }
264
533
 
534
+ // The button that takes a queued ticket back out of the queue.
535
+ export const CANCEL_LABEL = 'cancel'
536
+ const CANCEL_WIDTH = CANCEL_LABEL.length + BUTTON_CHROME + 1
537
+
265
538
  const asideWidth = (a: RowAside | null): number =>
266
- a === null ? 0 : a.text.length + (a.kind === 'work' ? BUTTON_CHROME : 0) + 1
539
+ a === null
540
+ ? 0
541
+ : a.text.length + (a.kind === 'work' || a.kind === 'resume' ? BUTTON_CHROME : 0) + 1 + (a.kind === 'queued' ? CANCEL_WIDTH : 0)
542
+
543
+ // The press the panel holds back while a turn runs, and releases when it ends.
544
+ export type HeldPress = { id: number; verb: 'work' | 'resume'; number: number }
267
545
 
268
546
  // What one row draws, cut to the pane width: mark column, `#n title`, type, right-hand part.
269
547
  export const rowLayout = (t: PaneTicket, ctx: RowContext): RowLayout => {
270
548
  const mark = ctx.isInPlay ? MARK : ctx.hasMarkColumn ? MARK_PAD : null
271
- const aside = asideOf(t, ctx.pendingWork)
549
+ const aside = asideOf(t, { number: ctx.pendingWork, phase: ctx.pendingPhase }, ctx.isInPlay)
272
550
  const type = t.type === null ? null : ` ${t.type}`
273
551
  const prefix = `#${t.number}`
274
552
  const room = ctx.columns - (mark?.length ?? 0) - asideWidth(aside) - (type?.length ?? 0) - prefix.length - 1