@marcomigozzi/trailhead 0.13.0 → 0.14.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 (61) hide show
  1. package/README.it.md +28 -3
  2. package/README.md +38 -3
  3. package/bin/lib/codex-projection.js +16 -9
  4. package/bin/lib/codex-projection.test.js +39 -11
  5. package/bin/lib/install-verify.js +10 -5
  6. package/bin/lib/install-verify.test.js +51 -0
  7. package/bin/trailhead.js +37 -25
  8. package/bin/trailhead.test.js +162 -5
  9. package/package.json +4 -1
  10. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  11. package/plugins/trailhead/commands/panel.md +10 -0
  12. package/plugins/trailhead/hooks/hooks.json +3 -18
  13. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  14. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  15. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  16. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  17. package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
  18. package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
  19. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  20. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  21. package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
  22. package/plugins/trailhead/hooks/mod/README.md +187 -0
  23. package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
  24. package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
  25. package/plugins/trailhead/hooks/mod/register.tsx +280 -0
  26. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  27. package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
  28. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  29. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  30. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  31. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  32. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  33. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  34. package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
  35. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
  36. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  37. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  38. package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
  39. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
  40. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  41. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  42. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  43. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  44. package/plugins/trailhead/skills/_shared/choices.md +3 -1
  45. package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
  46. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  47. package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
  48. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  49. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  50. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  51. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  52. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
  53. package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
  54. package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
  55. package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
  56. package/plugins/trailhead/tests/mod.test.ts +14 -0
  57. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  58. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  59. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  60. package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
  61. package/plugins/trailhead/types/index.d.ts +69 -0
@@ -0,0 +1,663 @@
1
+ // The map pane (#208): a docked Pane listing the active map's open tickets by state.
2
+ // It reads the active map from `.trailhead/active-map` and the tickets through `gh`, only
3
+ // while the pane is open, and never faster than MIN_REFRESH_MS. Claude Code 2.1.287+ only.
4
+ import { atom, read, update } from 'claude-code'
5
+ import type { EngineInterface, On } from 'claude-code'
6
+
7
+ import type { BurndownHistory, MapPaneData, MapSummary, PaneTicket, WorkState } from '../../types'
8
+ import {
9
+ ageLabel,
10
+ axisStart,
11
+ type BurndownSeries,
12
+ burndownEnd,
13
+ burndownSeries,
14
+ failureReason,
15
+ footerStatus,
16
+ groupTickets,
17
+ HISTORY_LIMIT,
18
+ LIST_LIMIT,
19
+ mapRowLayout,
20
+ nextViewed,
21
+ parseActiveMap,
22
+ parseHistory,
23
+ parseMap,
24
+ parseMaps,
25
+ parseTickets,
26
+ rasterCells,
27
+ refreshDecision,
28
+ rowLayout,
29
+ sparkline,
30
+ viewTarget,
31
+ } from './map-pane-view'
32
+ import { fit, isMissing, sameValue } from './work-state-view'
33
+
34
+ export const PANE_ID = 'trailhead-map'
35
+ export const PANE_TITLE = 'Map'
36
+ export const PANEL_COMMAND = 'trailhead:panel'
37
+ export const MIN_REFRESH_MS = 15_000
38
+ export const TIMER_MS = 180_000
39
+ export const GH_TIMEOUT_MS = 20_000
40
+ // A list unchanged for this long is still written, so its age label moves on.
41
+ const AGE_REFRESH_MS = 60_000
42
+ const ACTIVE_MAP_PATH = '.trailhead/active-map'
43
+ const TICKET_FIELDS = 'number,title,labels,assignees,url'
44
+ const MAP_FIELDS = 'title,body,url'
45
+ const HISTORY_FIELDS = 'number,labels,createdAt,closedAt'
46
+ const MAPS_FIELDS = 'number,title,url'
47
+ const MAPS_LIMIT = 100
48
+ // Same colours as the band.
49
+ const ACCENT = '#7aa2f7'
50
+ // The accent as a Raster colour (0x00RRGGBB), and the chart height in terminal rows.
51
+ const ACCENT_RGB = 0x7aa2f7
52
+ const CHART_ROWS = 3
53
+ // Space between ticket rows on the desktop, whose native buttons would touch; the terminal stays compact.
54
+ const DESKTOP_ROW_GAP = 0.5
55
+ const AMBER = '#e0af68'
56
+
57
+ const EMPTY: MapPaneData = {
58
+ map: null,
59
+ tickets: [],
60
+ maps: [],
61
+ unverifiedCount: 0,
62
+ isCapped: false,
63
+ history: null,
64
+ historyAt: null,
65
+ fetchedAt: null,
66
+ error: null,
67
+ isLoading: false,
68
+ }
69
+
70
+ export const mapPane = atom({ plugin: 'trailhead', key: 'mapPane' } as const, EMPTY)
71
+ export const activeMap = atom({ plugin: 'trailhead', key: 'activeMap' } as const, null as number | null)
72
+ // The map shown when it is not the active one (a click on the maps list); null follows the active map.
73
+ // The pane only reads `.trailhead/active-map`; the work verb is what changes it.
74
+ export const viewedMap = atom({ plugin: 'trailhead', key: 'viewedMap' } as const, null as number | null)
75
+ // The ticket whose `work` press is queued; one at a time.
76
+ export const pendingWork = atom({ plugin: 'trailhead', key: 'pendingWork' } as const, null as number | null)
77
+ // Set once the pane was opened by the command, by itself or closed by the person: never auto-opened after.
78
+ export const paneAutoOpened = atom({ plugin: 'trailhead', key: 'paneAutoOpened' } as const, false)
79
+ const PENDING = { plugin: 'trailhead', key: 'pendingWork' } as const
80
+ const WORK_COMMAND = 'trailhead:work'
81
+ // The built-in that empties the conversation; the work verb starts in a clean one.
82
+ const CLEAR_COMMAND = 'clear'
83
+ // The same refs the band's atoms are written under; read here to mark the ticket in play.
84
+ const workState = atom({ plugin: 'trailhead', key: 'workState' } as const, null as WorkState | null)
85
+ const isOrphaned = atom({ plugin: 'trailhead', key: 'isOrphaned' } as const, false)
86
+
87
+ type Dollar = EngineInterface
88
+ type Timer = { cancel: () => void }
89
+
90
+ const debug = ($: Dollar, message: string) => {
91
+ try {
92
+ $.ui.log(message, { to: 'debug' })
93
+ } catch {
94
+ // No log sink: nothing to tell.
95
+ }
96
+ }
97
+
98
+ // Module state, only for the machinery (queue, timers); everything drawn lives in atoms.
99
+ // A hot reload resets it, which costs at most one early refresh.
100
+ let queue: Promise<void> = Promise.resolve()
101
+ let generation = 0
102
+ let lastStartedAt: number | null = null
103
+ let trailing: Timer | null = null
104
+ let ticker: Timer | null = null
105
+ // What /clear would take from the pane, kept across it (module variables survive it, atoms do not).
106
+ type Carried = { isAutoOpened: boolean; viewed: number | null; active: number | null }
107
+ let carried: Carried | null = null
108
+ // The ticket whose `work` press is running: /clear empties the pendingWork atom meanwhile.
109
+ let workInFlight: number | null = null
110
+
111
+ async function readActiveMap($: Dollar): Promise<number | null> {
112
+ let next: number | null
113
+ try {
114
+ next = parseActiveMap((await $.fs.read(ACTIVE_MAP_PATH)) as string)
115
+ } catch (err) {
116
+ // A missing marker means no active map; any other failure is transient: keep the last one.
117
+ if (!isMissing(err)) return read($, activeMap)
118
+ next = null
119
+ }
120
+ if ((await read($, activeMap)) !== next) {
121
+ await update($, activeMap, () => next)
122
+ // The active map moved on (the work verb switched it): the view follows it again.
123
+ await update($, viewedMap, () => null)
124
+ }
125
+ return next
126
+ }
127
+
128
+ async function setPane($: Dollar, next: MapPaneData) {
129
+ if (!sameValue(await read($, mapPane), next)) await update($, mapPane, () => next)
130
+ }
131
+
132
+ type GhResult = { ok: true; stdout: string } | { ok: false; reason: string }
133
+
134
+ async function gh($: Dollar, args: readonly string[]): Promise<GhResult> {
135
+ try {
136
+ const out = await $.process.run(['gh', ...args], { timeoutMs: GH_TIMEOUT_MS })
137
+ return out.exitCode === 0 ? { ok: true, stdout: out.stdout } : { ok: false, reason: failureReason(out) }
138
+ } catch (error) {
139
+ return { ok: false, reason: failureReason({ error }) }
140
+ }
141
+ }
142
+
143
+ const readList = ($: Dollar, target: number) =>
144
+ Promise.all([
145
+ gh($, ['issue', 'view', String(target), '--json', MAP_FIELDS]),
146
+ gh($, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS]),
147
+ gh($, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'all', '--limit', String(HISTORY_LIMIT), '--json', HISTORY_FIELDS]),
148
+ readMaps($),
149
+ ])
150
+
151
+ // The open maps of the repo. A failed or unreadable read is null: the caller keeps the last good list.
152
+ const readMaps = async ($: Dollar): Promise<readonly MapSummary[] | null> => {
153
+ const out = await gh($, ['issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', String(MAPS_LIMIT), '--json', MAPS_FIELDS])
154
+ return out.ok ? parseMaps(out.stdout) : null
155
+ }
156
+
157
+ // What the pane compares to skip a write: times aside, but whether the history is as fresh as the list.
158
+ const shape = (d: MapPaneData) => ({ ...d, fetchedAt: 0, historyAt: d.historyAt === d.fetchedAt })
159
+
160
+ // The map a fetch reads: the viewed one, else the active one.
161
+ const readTarget = async ($: Dollar): Promise<number | null> => {
162
+ const active = await readActiveMap($)
163
+ return viewTarget(active, await read($, viewedMap))
164
+ }
165
+
166
+ // A viewed map that left the freshly read open list (it closed meanwhile) is no longer viewed.
167
+ async function dropClosedView($: Dollar, maps: readonly MapSummary[] | null) {
168
+ const viewed = await read($, viewedMap)
169
+ if (nextViewed(viewed, { activeChanged: false, maps }) !== viewed) await update($, viewedMap, () => null)
170
+ }
171
+
172
+ // One read of the viewed (else active) map. The result is dropped when the pane closed meanwhile
173
+ // (generation) or the target moved on, checked before every write; a moved target is read again.
174
+ async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
175
+ const target = await readTarget($)
176
+ if (token !== generation) return
177
+ const isStale = async () => {
178
+ if (token !== generation) return true
179
+ if ((await readTarget($)) === target) return false
180
+ wantsAnother()
181
+ return true
182
+ }
183
+ const before = await read($, mapPane)
184
+
185
+ if (target === null) {
186
+ // No map to show: the open maps alone, so one can be picked.
187
+ const maps = (await readMaps($)) ?? before.maps
188
+ if (await isStale()) return
189
+ return setPane($, { ...EMPTY, maps })
190
+ }
191
+ const isSameMap = before.map?.number === target
192
+ if (!isSameMap) {
193
+ if (await isStale()) return
194
+ await setPane($, { ...EMPTY, maps: before.maps, isLoading: true })
195
+ }
196
+
197
+ const [view, list, historyRead, mapsRead] = await readList($, target)
198
+ const maps = mapsRead ?? before.maps
199
+ await dropClosedView($, mapsRead)
200
+ const now = await $.clock.now()
201
+ if (await isStale()) return
202
+
203
+ const kept = isSameMap ? before : EMPTY
204
+ const failure = !view.ok ? view.reason : !list.ok ? list.reason : null
205
+ if (failure !== null) return setPane($, { ...kept, maps, error: failure, isLoading: false })
206
+ const map = view.ok ? parseMap(view.stdout, target) : null
207
+ const tickets = list.ok ? parseTickets(list.stdout) : null
208
+ if (map === null || tickets === null) return setPane($, { ...kept, maps, error: 'gh returned unreadable output', isLoading: false })
209
+
210
+ // A history that failed alone keeps the last good one of this map (with its own time), never another map's.
211
+ const history = historyRead.ok ? parseHistory(historyRead.stdout) : null
212
+ const historyPart = history !== null ? { history, historyAt: now } : { history: kept.history, historyAt: kept.historyAt }
213
+ const next: MapPaneData = { map, ...tickets, maps, ...historyPart, fetchedAt: now, error: null, isLoading: false }
214
+ const isFresh = before.fetchedAt !== null && now - before.fetchedAt < AGE_REFRESH_MS
215
+ if (isFresh && isSameMap && sameValue(shape(before), shape(next))) return
216
+ await setPane($, next)
217
+ }
218
+
219
+ async function isPaneUp($: Dollar): Promise<boolean> {
220
+ return (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
221
+ }
222
+
223
+ function stopTimers() {
224
+ generation += 1
225
+ // A fill timer dropped with the pane must not leave the next pane unfillable.
226
+ isFilling = false
227
+ trailing?.cancel()
228
+ trailing = null
229
+ ticker?.cancel()
230
+ ticker = null
231
+ }
232
+
233
+ // `bypassFloor`: the caller's last result was dropped unseen, so reading again is not a repeat.
234
+ type Request = { bypassFloor?: boolean }
235
+
236
+ // Throttled: inside the floor it keeps ONE trailing refresh for the rest of it, so an event
237
+ // just after a refresh still shows.
238
+ async function decide($: Dollar, request: Request) {
239
+ if (!(await isPaneUp($))) return
240
+ ticker ??= $.clock.every(TIMER_MS, () => void requestRefresh($))
241
+ const now = await $.clock.now()
242
+ const decision = request.bypassFloor === true ? { run: true as const } : refreshDecision(now, lastStartedAt, MIN_REFRESH_MS)
243
+ if (!decision.run) {
244
+ if (trailing === null) {
245
+ trailing = $.clock.after(decision.waitMs, () => {
246
+ trailing = null
247
+ void requestRefresh($)
248
+ })
249
+ }
250
+ return
251
+ }
252
+ lastStartedAt = now
253
+ trailing?.cancel()
254
+ trailing = null
255
+ await fetchMap($, generation, () => void requestRefresh($, { bypassFloor: true }))
256
+ }
257
+
258
+ // Does nothing while the pane is closed: the data exists only for the pane.
259
+ export function requestRefresh($: Dollar, request: Request = {}): Promise<void> {
260
+ queue = queue.then(() => decide($, request)).catch(err => debug($, `trailhead mod: map pane refresh failed: ${String(err)}`))
261
+ return queue
262
+ }
263
+
264
+ // Local file read, no `gh`: the active map is known whether or not the pane is open.
265
+ const safeReadActiveMap = ($: Dollar) =>
266
+ readActiveMap($).catch(err => debug($, `trailhead mod: active map read failed: ${String(err)}`))
267
+
268
+ async function carryOverClear($: Dollar) {
269
+ carried = {
270
+ isAutoOpened: await read($, paneAutoOpened),
271
+ viewed: await read($, viewedMap),
272
+ active: await read($, activeMap),
273
+ }
274
+ }
275
+
276
+ // The atoms are empty again: put back what the person had set, then fill the pane without waiting
277
+ // for a turn. The active map is read first (its change from null would reset the view), and a view
278
+ // comes back only when the active map is the one it was set against.
279
+ async function restoreAfterClear($: Dollar) {
280
+ const kept = carried
281
+ carried = null
282
+ try {
283
+ if (kept?.isAutoOpened === true) await update($, paneAutoOpened, () => true)
284
+ const active = await readActiveMap($)
285
+ const viewed = kept?.viewed ?? null
286
+ if (viewed !== null && viewed !== active && active === kept?.active) await update($, viewedMap, () => viewed)
287
+ } catch (err) {
288
+ debug($, `trailhead mod: map pane restore after clear failed: ${String(err)}`)
289
+ }
290
+ void requestRefresh($, { bypassFloor: true })
291
+ }
292
+
293
+ // Edits of the tracker through the gh CLI: the narrow set worth a refresh right away.
294
+ const GH_ISSUE_WRITE = /\bgh\b[^\n;&|]*\bissue\s+(edit|close|create|comment|reopen)\b/
295
+
296
+ // Clears the conversation, then runs `/trailhead:work <n>` in the new one; the host queues both
297
+ // behind a running turn. The claim is taken with the version it read, so two presses never both
298
+ // pass, and `workInFlight` holds the next ones off while the clear has emptied the atom.
299
+ // Cleared once the calls settle.
300
+ async function startWork($: Dollar, number: number) {
301
+ if (workInFlight !== null) return
302
+ const { value, version } = await $.state.get(PENDING)
303
+ if (value != null) return
304
+ if (!(await $.state.set(PENDING, number, { ifVersion: version })).isSet) return
305
+ workInFlight = number
306
+ try {
307
+ const before = await $.session.id()
308
+ await $.command.run({ command: CLEAR_COMMAND, args: '' })
309
+ // A clear another hook answered without running resolves too: the verb must not start in the old conversation.
310
+ if ((await $.session.id()) === before) return debug($, `trailhead mod: work ${number} skipped, the conversation was not cleared`)
311
+ // The clear emptied the atom: the row is still queued in the new session.
312
+ await update($, pendingWork, () => number)
313
+ await $.command.run({ command: WORK_COMMAND, args: String(number) })
314
+ } catch (err) {
315
+ debug($, `trailhead mod: work ${number} did not start: ${String(err)}`)
316
+ } finally {
317
+ workInFlight = null
318
+ await update($, pendingWork, () => null)
319
+ }
320
+ }
321
+
322
+ // A click on a map of the list: show it, or (the active map, or null) go back to following the active one.
323
+ // A view change is not a repeat read, so the floor does not apply.
324
+ async function viewMap($: Dollar, number: number | null) {
325
+ const active = await read($, activeMap)
326
+ await update($, viewedMap, () => (number === active ? null : number))
327
+ void requestRefresh($, { bypassFloor: true })
328
+ }
329
+
330
+ // The press handler of a map click or the back button: a failed view change is logged, never thrown.
331
+ const pressView = ($: Dollar, number: number | null): void => {
332
+ void viewMap($, number).catch(err => debug($, `trailhead mod: map view failed: ${String(err)}`))
333
+ }
334
+
335
+ // Opening always refreshes. A render cannot write state, not even from work it left running,
336
+ // so the auto-open runs from a zero-delay timer, a dispatch of its own; `isOpening` keeps two
337
+ // renders from opening twice before the flag is written.
338
+ let isOpening = false
339
+ // Same for the fill an empty pane asks for from its render.
340
+ let isFilling = false
341
+
342
+ async function openPane($: Dollar) {
343
+ await update($, paneAutoOpened, () => true)
344
+ const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
345
+ if (opened.isPlaced) void requestRefresh($)
346
+ return opened
347
+ }
348
+
349
+ async function autoOpen($: Dollar) {
350
+ if (isOpening) return
351
+ isOpening = true
352
+ try {
353
+ if (!(await $.ui.panes()).some(pane => pane.id === PANE_ID)) await openPane($)
354
+ // Found up already (a reload with the pane showing): nothing to open, and renders stop asking.
355
+ else await update($, paneAutoOpened, () => true)
356
+ } catch (err) {
357
+ debug($, `trailhead mod: map pane auto-open failed: ${String(err)}`)
358
+ } finally {
359
+ isOpening = false
360
+ }
361
+ }
362
+
363
+ async function togglePanel($: Dollar): Promise<string> {
364
+ if ((await $.ui.panes()).some(pane => pane.id === PANE_ID)) {
365
+ await $.ui.close({ id: PANE_ID })
366
+ return 'Map panel closed.'
367
+ }
368
+ const opened = await openPane($)
369
+ return opened.isPlaced ? 'Map panel opened.' : `Map panel not shown: ${opened.reason}`
370
+ }
371
+
372
+ // The close button: the pane goes (its `ui.close` hook stops the timers). The press counts as the
373
+ // person closing it, so the auto-open never fires again this session.
374
+ async function closePane($: Dollar) {
375
+ await $.ui.close({ id: PANE_ID })
376
+ await update($, paneAutoOpened, () => true)
377
+ }
378
+
379
+ const dim = { dimColor: true } as const
380
+ const bold = { bold: true } as const
381
+ const accent = { bold: true, color: ACCENT } as const
382
+
383
+ const unverifiedLine = (n: number) =>
384
+ n === 1 ? '1 unverified ticket hidden (quarantined)' : `${n} unverified tickets hidden (quarantined)`
385
+
386
+ export function registerMapPane(on: On) {
387
+ // The matchers below always hold: the validator refuses a second hook on an event
388
+ // when neither has one, and register.tsx already hooks these events bare.
389
+ on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
390
+ const result = await next(e)
391
+ await safeReadActiveMap($)
392
+ void requestRefresh($)
393
+ return result
394
+ })
395
+
396
+ on('turn.complete', { reason: ['answer', 'aborted', 'refusal', 'error'] }, async ($, e, next) => {
397
+ const result = await next(e)
398
+ await safeReadActiveMap($)
399
+ void requestRefresh($)
400
+ return result
401
+ })
402
+
403
+ // A /clear empties the atoms: note what the pane must get back, then restore it in the new session.
404
+ on('session.end', { reason: 'clear' }, async ($, e, next) => {
405
+ await carryOverClear($).catch(err => debug($, `trailhead mod: map pane carry-over failed: ${String(err)}`))
406
+ return next(e)
407
+ })
408
+
409
+ on('classic.SessionStart', { source: 'clear' }, async ($, e, next) => {
410
+ const result = await next(e)
411
+ await restoreAfterClear($)
412
+ return result
413
+ })
414
+
415
+ // After the call ran: a Bash `gh issue` write changed the tracker.
416
+ on('tool.call', { tool: 'Bash', command: GH_ISSUE_WRITE }, async ($, e, next) => {
417
+ const result = await next(e)
418
+ void requestRefresh($)
419
+ return result
420
+ // This hook never judges the call: if it fails, the call goes on untouched.
421
+ }).catch((_$, e, next) => next(e))
422
+
423
+ // /trailhead:panel is a plugin command file; this answers it, so the model never runs.
424
+ on('command.run', { command: PANEL_COMMAND }, async $ => ({ text: await togglePanel($) }))
425
+
426
+ // Only in fullscreen, where a pane docks beside the transcript; this bare matcher is
427
+ // narrower than the band's, and the band's own hook (in register.tsx) draws after it.
428
+ on('ui.render', { component: 'AbovePrompt', viewport: { isFullscreen: true } }, async ($, e, next) => {
429
+ if ((await read($, activeMap)) !== null && !(await read($, paneAutoOpened))) $.clock.after(0, () => void autoOpen($))
430
+ return next(e)
431
+ })
432
+
433
+ on('ui.close', { id: PANE_ID }, async ($, e, next) => {
434
+ const result = await next(e)
435
+ stopTimers()
436
+ // The person closing it by hand ends the auto-open for good.
437
+ if (e.origin.kind === 'person') await update($, paneAutoOpened, () => true)
438
+ return result
439
+ })
440
+
441
+ on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
442
+ const ui = $.ui.resolve(e)
443
+ const { Box, Text, Button, Link } = ui
444
+ // Raster is the terminal surface's only; elsewhere the constructor is not a real element.
445
+ const Raster = e.surface === 'terminal' && 'Raster' in ui ? ui.Raster : undefined
446
+ const data = await read($, mapPane)
447
+ const active = await read($, activeMap)
448
+ const viewed = await read($, viewedMap)
449
+ const target = viewTarget(active, viewed)
450
+ const play = await read($, workState)
451
+ const orphaned = await read($, isOrphaned)
452
+ const pending = await read($, pendingWork)
453
+ const now = await $.clock.now()
454
+ const cols = e.props.bodyColumns
455
+ const isDock = e.props.placement === 'dock'
456
+ const gap = isDock ? 1 : 0
457
+
458
+ const text = (key: string, props: object, children: string) => (
459
+ <Box key={key}>
460
+ <Text {...props}>{children}</Text>
461
+ </Box>
462
+ )
463
+ const link = (url: string, label: string) => (url === '' ? label : <Link href={url} label={label} />)
464
+
465
+ // The open maps, one row each; shown with several maps open (or with no active map, to pick one).
466
+ const mapRow = (m: MapSummary) => {
467
+ const r = mapRowLayout(m, { active, viewed, columns: cols })
468
+ return (
469
+ <Box key={`map-row-${m.number}`}>
470
+ {text(`map-mark-${m.number}`, r.isActive ? accent : {}, r.mark)}
471
+ {r.isPressable ? (
472
+ <Button
473
+ key={`map-${m.number}`}
474
+ label={r.label}
475
+ onPress={() => pressView($, m.number)}
476
+ />
477
+ ) : (
478
+ text(`map-label-${m.number}`, bold, r.label)
479
+ )}
480
+ {r.aside === null ? null : text(`map-aside-${m.number}`, dim, r.aside)}
481
+ </Box>
482
+ )
483
+ }
484
+ const mapsGroup =
485
+ data.maps.length > 1 || (active === null && data.maps.length > 0) ? (
486
+ <Box key="group-maps" flexDirection="column" marginTop={gap}>
487
+ {text('head-maps', bold, `Maps · ${data.maps.length}`)}
488
+ {data.maps.map(mapRow)}
489
+ </Box>
490
+ ) : null
491
+ // Viewing a map other than the active one: say so, with the way back.
492
+ const viewing =
493
+ viewed === null || viewed === active ? null : (
494
+ <Box key="viewing" gap={1}>
495
+ {text('viewing-text', dim, active === null ? `viewing #${viewed}` : `viewing #${viewed} · active #${active}`)}
496
+ <Button
497
+ key="back"
498
+ label={active === null ? 'back' : 'back to active'}
499
+ onPress={() => pressView($, null)}
500
+ />
501
+ </Box>
502
+ )
503
+
504
+ if (target === null) {
505
+ return (
506
+ <Box flexDirection="column" width={cols}>
507
+ {text('empty', dim, 'No active map. Run /trailhead:map to pick one.')}
508
+ {mapsGroup}
509
+ </Box>
510
+ )
511
+ }
512
+ const isCurrent = data.map?.number === target
513
+ const isLoading = !isCurrent && (data.isLoading || data.error === null)
514
+ // A pane the person opened (or that waited undrawn) has no data yet: fill it, once. Like the
515
+ // auto-open it leaves the render through a zero-delay timer; inside the floor it waits for
516
+ // the trailing refresh rather than the slow timer.
517
+ if (!isCurrent && !data.isLoading && data.error === null && !isFilling) {
518
+ isFilling = true
519
+ $.clock.after(0, () => {
520
+ isFilling = false
521
+ void requestRefresh($)
522
+ })
523
+ }
524
+
525
+ const map = isCurrent ? data.map : null
526
+ const heading = map === null ? `map #${target}` : fit(`#${map.number} ${map.title}`, cols)
527
+ const header = (
528
+ <Box key="header" flexDirection="column">
529
+ <Box key="map">
530
+ <Text {...accent}>{map === null ? heading : link(map.url, heading)}</Text>
531
+ </Box>
532
+ {map?.destination == null ? null : text('destination', dim, fit(map.destination, cols))}
533
+ </Box>
534
+ )
535
+ if (isLoading) {
536
+ return (
537
+ <Box flexDirection="column">
538
+ {header}
539
+ {viewing}
540
+ {text('loading', dim, 'Loading tickets…')}
541
+ {mapsGroup}
542
+ </Box>
543
+ )
544
+ }
545
+
546
+ const tickets = isCurrent ? data.tickets : []
547
+ const groups = groupTickets(tickets)
548
+ const isInPlay = (t: PaneTicket) => play !== null && !orphaned && play.ticket === t.number
549
+
550
+ const row = (t: PaneTicket, hasMarkColumn: boolean) => {
551
+ const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, columns: cols })
552
+ const style = r.isInPlay ? accent : r.isDim ? dim : {}
553
+ const holder = r.isInPlay ? { color: ACCENT } : r.isDim ? dim : {}
554
+ return (
555
+ <Box key={`row-${t.number}`} width={cols} justifyContent="space-between">
556
+ <Box>
557
+ {r.mark === null ? null : text(`mark-${t.number}`, r.isInPlay ? accent : {}, r.mark)}
558
+ <Box key={`ticket-${t.number}`}>
559
+ <Text {...style}>{link(t.url, r.label)}</Text>
560
+ </Box>
561
+ {r.type === null ? null : text(`type-${t.number}`, dim, r.type)}
562
+ </Box>
563
+ {r.aside?.kind === 'holder' ? text(`holder-${t.number}`, holder, r.aside.text) : null}
564
+ {r.aside?.kind === 'queued' ? text(`queued-${t.number}`, { color: AMBER }, r.aside.text) : null}
565
+ {r.aside?.kind === 'work' ? (
566
+ <Button
567
+ key={`work-${t.number}`}
568
+ label="work"
569
+ variant="primary"
570
+ onPress={() => void startWork($, t.number).catch(err => debug($, `trailhead mod: work press failed: ${String(err)}`))}
571
+ />
572
+ ) : null}
573
+ </Box>
574
+ )
575
+ }
576
+
577
+ const group = (key: string, name: string, rows: readonly PaneTicket[]) => {
578
+ if (rows.length === 0) return null
579
+ const hasMarkColumn = rows.some(isInPlay)
580
+ return (
581
+ <Box
582
+ key={`group-${key}`}
583
+ flexDirection="column"
584
+ marginTop={gap}
585
+ {...(e.surface === 'desktop' ? { rowGap: DESKTOP_ROW_GAP } : {})}
586
+ >
587
+ {text(`head-${key}`, bold, `${name} · ${rows.length}`)}
588
+ {rows.map(t => row(t, hasMarkColumn))}
589
+ </Box>
590
+ )
591
+ }
592
+
593
+ const status =
594
+ data.error === null
595
+ ? `updated ${ageLabel(now - (data.fetchedAt ?? now))}`
596
+ : data.fetchedAt === null
597
+ ? `⚠ ${data.error}`
598
+ : `⚠ ${data.error} · list from ${ageLabel(now - data.fetchedAt)}`
599
+ const footer = footerStatus(status, cols)
600
+
601
+ const end = burndownEnd(data, now)
602
+ const series = isCurrent && data.history !== null ? burndownSeries(data.history, end.at, cols) : null
603
+ const burndown = (s: BurndownSeries, history: BurndownHistory) => (
604
+ <Box key="burndown-section" flexDirection="column" marginTop={gap}>
605
+ {text('burndown-head', bold, 'Burn-down')}
606
+ {text('burndown-counts', dim, fit(`open ${s.open} · done ${s.done} · removed ${s.removed} · peak ${s.peak}`, cols))}
607
+ {Raster !== undefined ? (
608
+ <Raster key="burndown" columns={cols} rows={CHART_ROWS} cells={rasterCells(s.values, s.peak, CHART_ROWS, ACCENT_RGB)} />
609
+ ) : (
610
+ text('burndown-spark', { color: ACCENT }, sparkline(s.values, s.peak))
611
+ )}
612
+ <Box key="burndown-axis" width={cols} justifyContent="space-between">
613
+ {text('burndown-axis-start', dim, axisStart(s.startedAt, end.at))}
614
+ {text('burndown-axis-end', dim, end.label)}
615
+ </Box>
616
+ {history.isCapped ? text('burndown-capped', dim, `history capped at ${HISTORY_LIMIT}: starts at the oldest ticket read`) : null}
617
+ </Box>
618
+ )
619
+
620
+ return (
621
+ <Box flexDirection="column" width={cols} {...(isDock ? { minHeight: e.props.scroll.bodyRows } : {})}>
622
+ {header}
623
+ {viewing}
624
+ {mapsGroup}
625
+ {group('frontier', 'Frontier', groups.frontier)}
626
+ {group('in-progress', 'In progress', groups.inProgress)}
627
+ {group('blocked', 'Blocked', groups.blocked)}
628
+ {isCurrent && data.error === null && tickets.length === 0 ? (
629
+ <Box key="no-tickets" marginTop={gap}>
630
+ <Text {...dim}>No open tickets. Fog may remain: /trailhead:map</Text>
631
+ </Box>
632
+ ) : null}
633
+ {!isCurrent || data.unverifiedCount === 0 ? null : (
634
+ <Box key="unverified" marginTop={gap}>
635
+ <Text {...dim}>{unverifiedLine(data.unverifiedCount)}</Text>
636
+ </Box>
637
+ )}
638
+ {!isCurrent || !data.isCapped ? null : (
639
+ <Box key="capped" marginTop={gap}>
640
+ <Text {...dim}>{`list capped at ${LIST_LIMIT}`}</Text>
641
+ </Box>
642
+ )}
643
+ {series === null || data.history === null ? null : burndown(series, data.history)}
644
+ <Box key="spacer" flexGrow={1} />
645
+ <Box key="footer" flexDirection="column">
646
+ {text('rule', dim, '─'.repeat(cols))}
647
+ <Box key="footer-row" width={cols} justifyContent="space-between">
648
+ <Box gap={1}>
649
+ <Button key="refresh" label="refresh" onPress={() => void requestRefresh($)} />
650
+ {footer.isInline ? text('age', dim, footer.text) : null}
651
+ </Box>
652
+ <Button
653
+ key="close"
654
+ label="close"
655
+ onPress={() => void closePane($).catch(err => debug($, `trailhead mod: close press failed: ${String(err)}`))}
656
+ />
657
+ </Box>
658
+ {footer.isInline ? null : <Box key="age-row">{text('age', dim, footer.text)}</Box>}
659
+ </Box>
660
+ </Box>
661
+ )
662
+ })
663
+ }