@marcomigozzi/trailhead 0.14.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 (92) hide show
  1. package/README.it.md +118 -30
  2. package/README.md +107 -29
  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 +1 -1
  8. package/bin/lib/codex-projection.test.js +41 -0
  9. package/bin/report-status-docs.test.js +195 -0
  10. package/bin/resolve-helpers-docs.test.js +36 -0
  11. package/bin/scope-confirm-skip-docs.test.js +70 -0
  12. package/bin/trailhead.js +3 -3
  13. package/bin/trailhead.test.js +341 -1
  14. package/bin/uat-setup-docs.test.js +65 -0
  15. package/package.json +1 -1
  16. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  17. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  18. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  19. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  20. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  21. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  22. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  23. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  24. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  25. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  26. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  27. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  28. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  29. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  30. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  31. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  32. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  33. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  34. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  35. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  36. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  37. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  38. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  39. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  40. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  41. package/plugins/trailhead/hooks/mod/README.md +38 -6
  42. package/plugins/trailhead/hooks/mod/map-pane-view.ts +168 -15
  43. package/plugins/trailhead/hooks/mod/map-pane.tsx +275 -106
  44. package/plugins/trailhead/hooks/mod/register.tsx +15 -2
  45. package/plugins/trailhead/hooks/mod/work-state-view.ts +17 -0
  46. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  47. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  48. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  49. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  50. package/plugins/trailhead/skills/_shared/choices.md +4 -2
  51. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  52. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  53. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  54. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  55. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  56. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  57. package/plugins/trailhead/skills/_shared/teamwork.md +16 -7
  58. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  59. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  60. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  61. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  62. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  63. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  64. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  65. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  66. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  67. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  68. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  69. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  70. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  71. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  72. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  73. package/plugins/trailhead/skills/trailhead-work/SKILL.md +14 -12
  74. package/plugins/trailhead/skills/trailhead-work/references/auto.md +4 -4
  75. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  76. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +26 -10
  77. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  78. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  79. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  80. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  81. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  82. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  83. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  84. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  85. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  86. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  87. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  88. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  89. package/plugins/trailhead/tests/map-pane-ui.test.ts +566 -19
  90. package/plugins/trailhead/tests/map-pane-view.test.ts +304 -16
  91. package/plugins/trailhead/tests/work-state-view.test.ts +25 -1
  92. package/plugins/trailhead/types/index.d.ts +37 -3
@@ -4,7 +4,7 @@
4
4
  import { atom, read, update } from 'claude-code'
5
5
  import type { EngineInterface, On } from 'claude-code'
6
6
 
7
- import type { BurndownHistory, MapPaneData, MapSummary, PaneTicket, WorkState } from '../../types'
7
+ import type { BurndownHistory, DashboardPaneData, MapCounts, MapPaneData, MapSummary, PaneTicket, UatParent, WorkState } from '../../types'
8
8
  import {
9
9
  ageLabel,
10
10
  axisStart,
@@ -14,20 +14,29 @@ import {
14
14
  failureReason,
15
15
  footerStatus,
16
16
  groupTickets,
17
+ headingRoom,
17
18
  HISTORY_LIMIT,
18
19
  LIST_LIMIT,
20
+ MAP_LABEL,
19
21
  mapRowLayout,
20
22
  nextViewed,
21
23
  parseActiveMap,
24
+ parseDashboard,
22
25
  parseHistory,
23
26
  parseMap,
24
27
  parseMaps,
28
+ parseMapsRead,
25
29
  parseTickets,
30
+ parseUatParents,
26
31
  rasterCells,
27
32
  refreshDecision,
28
33
  rowLayout,
29
34
  sparkline,
35
+ uatParentLine,
30
36
  viewTarget,
37
+ withUatParents,
38
+ whiteboardHeading,
39
+ whiteboardRowLayout,
31
40
  } from './map-pane-view'
32
41
  import { fit, isMissing, sameValue } from './work-state-view'
33
42
 
@@ -41,10 +50,13 @@ export const GH_TIMEOUT_MS = 20_000
41
50
  const AGE_REFRESH_MS = 60_000
42
51
  const ACTIVE_MAP_PATH = '.trailhead/active-map'
43
52
  const TICKET_FIELDS = 'number,title,labels,assignees,url'
53
+ const UAT_FIELDS = 'number,body'
44
54
  const MAP_FIELDS = 'title,body,url'
45
55
  const HISTORY_FIELDS = 'number,labels,createdAt,closedAt'
46
56
  const MAPS_FIELDS = 'number,title,url'
47
57
  const MAPS_LIMIT = 100
58
+ const MAPS_QUERY = `query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){issues(first:${MAPS_LIMIT},states:OPEN,labels:["trailhead:map"]){nodes{number title url subIssuesSummary{total completed}}}whiteboardOpen:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}whiteboardAll:issues(labels:["trailhead:whiteboard"]){totalCount}}}`
59
+ const DASHBOARD_FIELDS = 'number,url'
48
60
  // Same colours as the band.
49
61
  const ACCENT = '#7aa2f7'
50
62
  // The accent as a Raster colour (0x00RRGGBB), and the chart height in terminal rows.
@@ -58,6 +70,7 @@ const EMPTY: MapPaneData = {
58
70
  map: null,
59
71
  tickets: [],
60
72
  maps: [],
73
+ whiteboard: null,
61
74
  unverifiedCount: 0,
62
75
  isCapped: false,
63
76
  history: null,
@@ -67,11 +80,24 @@ const EMPTY: MapPaneData = {
67
80
  isLoading: false,
68
81
  }
69
82
 
83
+ const EMPTY_DASHBOARD: DashboardPaneData = {
84
+ dashboard: null,
85
+ tickets: [],
86
+ unverifiedCount: 0,
87
+ isCapped: false,
88
+ fetchedAt: null,
89
+ error: null,
90
+ isLoading: false,
91
+ }
92
+
70
93
  export const mapPane = atom({ plugin: 'trailhead', key: 'mapPane' } as const, EMPTY)
71
94
  export const activeMap = atom({ plugin: 'trailhead', key: 'activeMap' } as const, null as number | null)
72
95
  // The map shown when it is not the active one (a click on the maps list); null follows the active map.
73
96
  // The pane only reads `.trailhead/active-map`; the work verb is what changes it.
74
97
  export const viewedMap = atom({ plugin: 'trailhead', key: 'viewedMap' } as const, null as number | null)
98
+ // The dashboard view (maps index + whiteboard tickets) in place of a map; a map press leaves it.
99
+ export const isDashboard = atom({ plugin: 'trailhead', key: 'isDashboard' } as const, false)
100
+ export const dashboardPane = atom({ plugin: 'trailhead', key: 'dashboardPane' } as const, EMPTY_DASHBOARD)
75
101
  // The ticket whose `work` press is queued; one at a time.
76
102
  export const pendingWork = atom({ plugin: 'trailhead', key: 'pendingWork' } as const, null as number | null)
77
103
  // Set once the pane was opened by the command, by itself or closed by the person: never auto-opened after.
@@ -83,6 +109,7 @@ const CLEAR_COMMAND = 'clear'
83
109
  // The same refs the band's atoms are written under; read here to mark the ticket in play.
84
110
  const workState = atom({ plugin: 'trailhead', key: 'workState' } as const, null as WorkState | null)
85
111
  const isOrphaned = atom({ plugin: 'trailhead', key: 'isOrphaned' } as const, false)
112
+ const ownTicket = atom({ plugin: 'trailhead', key: 'ownTicket' } as const, null as number | null)
86
113
 
87
114
  type Dollar = EngineInterface
88
115
  type Timer = { cancel: () => void }
@@ -103,7 +130,7 @@ let lastStartedAt: number | null = null
103
130
  let trailing: Timer | null = null
104
131
  let ticker: Timer | null = null
105
132
  // 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 }
133
+ type Carried = { isAutoOpened: boolean; viewed: number | null; active: number | null; isDashboard: boolean }
107
134
  let carried: Carried | null = null
108
135
  // The ticket whose `work` press is running: /clear empties the pendingWork atom meanwhile.
109
136
  let workInFlight: number | null = null
@@ -146,12 +173,26 @@ const readList = ($: Dollar, target: number) =>
146
173
  gh($, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS]),
147
174
  gh($, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'all', '--limit', String(HISTORY_LIMIT), '--json', HISTORY_FIELDS]),
148
175
  readMaps($),
176
+ readUat($, `trailhead:map-${target}`),
149
177
  ])
150
178
 
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> => {
179
+ // The pending UAT tickets of one scope (a map label, or the whiteboard) with their bodies, for the parent
180
+ // each one tests. A failed or unreadable read is null: the rows still group by label, only the parent line goes.
181
+ const readUat = async ($: Dollar, scope: string): Promise<ReadonlyMap<number, UatParent> | null> => {
182
+ const out = await gh($, ['issue', 'list', '--label', 'trailhead:uat', '--label', scope, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', UAT_FIELDS])
183
+ return out.ok ? parseUatParents(out.stdout) : null
184
+ }
185
+
186
+ // The open maps of the repo and the whiteboard's counts. A failed or unreadable maps read is null: the
187
+ // caller keeps the last good list. One GraphQL call gives every map with its sub-issue counts and the
188
+ // whiteboard's; if it fails or is unreadable the name-only `gh issue list` runs instead (counts null, and
189
+ // the whiteboard null: the row is drawn name-only, never stale). Both bounded at MAPS_LIMIT open maps.
190
+ const readMaps = async ($: Dollar): Promise<{ maps: readonly MapSummary[] | null; whiteboard: MapCounts | null }> => {
191
+ const counted = await gh($, ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MAPS_QUERY}`])
192
+ const withCounts = counted.ok ? parseMapsRead(counted.stdout) : null
193
+ if (withCounts !== null) return withCounts
153
194
  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
195
+ return { maps: out.ok ? parseMaps(out.stdout) : null, whiteboard: null }
155
196
  }
156
197
 
157
198
  // What the pane compares to skip a write: times aside, but whether the history is as fresh as the list.
@@ -176,7 +217,7 @@ async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
176
217
  if (token !== generation) return
177
218
  const isStale = async () => {
178
219
  if (token !== generation) return true
179
- if ((await readTarget($)) === target) return false
220
+ if (!(await read($, isDashboard)) && (await readTarget($)) === target) return false
180
221
  wantsAnother()
181
222
  return true
182
223
  }
@@ -184,38 +225,76 @@ async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
184
225
 
185
226
  if (target === null) {
186
227
  // No map to show: the open maps alone, so one can be picked.
187
- const maps = (await readMaps($)) ?? before.maps
228
+ const mapsRead = await readMaps($)
229
+ const maps = mapsRead.maps ?? before.maps
188
230
  if (await isStale()) return
189
- return setPane($, { ...EMPTY, maps })
231
+ return setPane($, { ...EMPTY, maps, whiteboard: mapsRead.whiteboard })
190
232
  }
191
233
  const isSameMap = before.map?.number === target
192
234
  if (!isSameMap) {
193
235
  if (await isStale()) return
194
- await setPane($, { ...EMPTY, maps: before.maps, isLoading: true })
236
+ await setPane($, { ...EMPTY, maps: before.maps, whiteboard: before.whiteboard, isLoading: true })
195
237
  }
196
238
 
197
- const [view, list, historyRead, mapsRead] = await readList($, target)
198
- const maps = mapsRead ?? before.maps
199
- await dropClosedView($, mapsRead)
239
+ const [view, list, historyRead, mapsRead, uatParents] = await readList($, target)
240
+ const maps = mapsRead.maps ?? before.maps
241
+ const { whiteboard } = mapsRead
242
+ await dropClosedView($, mapsRead.maps)
200
243
  const now = await $.clock.now()
201
244
  if (await isStale()) return
202
245
 
203
246
  const kept = isSameMap ? before : EMPTY
204
247
  const failure = !view.ok ? view.reason : !list.ok ? list.reason : null
205
- if (failure !== null) return setPane($, { ...kept, maps, error: failure, isLoading: false })
248
+ if (failure !== null) return setPane($, { ...kept, maps, whiteboard, error: failure, isLoading: false })
206
249
  const map = view.ok ? parseMap(view.stdout, target) : null
207
250
  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 })
251
+ if (map === null || tickets === null) return setPane($, { ...kept, maps, whiteboard, error: 'gh returned unreadable output', isLoading: false })
209
252
 
210
253
  // A history that failed alone keeps the last good one of this map (with its own time), never another map's.
211
254
  const history = historyRead.ok ? parseHistory(historyRead.stdout) : null
212
255
  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 }
256
+ const next: MapPaneData = { map, ...tickets, tickets: withUatParents(tickets.tickets, uatParents), maps, whiteboard, ...historyPart, fetchedAt: now, error: null, isLoading: false }
214
257
  const isFresh = before.fetchedAt !== null && now - before.fetchedAt < AGE_REFRESH_MS
215
258
  if (isFresh && isSameMap && sameValue(shape(before), shape(next))) return
216
259
  await setPane($, next)
217
260
  }
218
261
 
262
+ // The whiteboard's open tickets (map-less) and the pinned index, plus the open maps. Same drop rule
263
+ // as a map read: a result is not committed once the pane closed or the dashboard was left.
264
+ async function fetchDashboard($: Dollar, token: number, wantsAnother: () => void) {
265
+ const isStale = async () => {
266
+ if (token !== generation) return true
267
+ if (await read($, isDashboard)) return false
268
+ wantsAnother()
269
+ return true
270
+ }
271
+ const before = await read($, dashboardPane)
272
+ if (before.fetchedAt === null && !before.isLoading && before.error === null) {
273
+ await update($, dashboardPane, () => ({ ...before, isLoading: true }))
274
+ }
275
+ const [list, index, mapsRead, uatParents] = await Promise.all([
276
+ gh($, ['issue', 'list', '--label', 'trailhead:ticket', '--label', 'trailhead:whiteboard', '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS]),
277
+ gh($, ['issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', DASHBOARD_FIELDS]),
278
+ readMaps($),
279
+ readUat($, 'trailhead:whiteboard'),
280
+ ])
281
+ const now = await $.clock.now()
282
+ if (await isStale()) return
283
+ const shown = await read($, mapPane)
284
+ await setPane($, { ...shown, maps: mapsRead.maps ?? shown.maps, whiteboard: mapsRead.whiteboard })
285
+ await dropClosedView($, mapsRead.maps)
286
+
287
+ const kept = await read($, dashboardPane)
288
+ const tickets = list.ok ? parseTickets(list.stdout, { isWhiteboard: true }) : null
289
+ const error = !list.ok ? list.reason : tickets === null ? 'gh returned unreadable output' : null
290
+ if (tickets === null) return update($, dashboardPane, () => ({ ...kept, error, isLoading: false }))
291
+ // A failed index read alone keeps the last link: the list is what the view is about.
292
+ const parsed = index.ok ? parseDashboard(index.stdout) : null
293
+ const dashboard = parsed === null ? kept.dashboard : parsed.ref
294
+ const next: DashboardPaneData = { dashboard, ...tickets, tickets: withUatParents(tickets.tickets, uatParents), fetchedAt: now, error: null, isLoading: false }
295
+ if (!sameValue(kept, next)) await update($, dashboardPane, () => next)
296
+ }
297
+
219
298
  async function isPaneUp($: Dollar): Promise<boolean> {
220
299
  return (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
221
300
  }
@@ -252,7 +331,9 @@ async function decide($: Dollar, request: Request) {
252
331
  lastStartedAt = now
253
332
  trailing?.cancel()
254
333
  trailing = null
255
- await fetchMap($, generation, () => void requestRefresh($, { bypassFloor: true }))
334
+ const again = () => void requestRefresh($, { bypassFloor: true })
335
+ if (await read($, isDashboard)) await fetchDashboard($, generation, again)
336
+ else await fetchMap($, generation, again)
256
337
  }
257
338
 
258
339
  // Does nothing while the pane is closed: the data exists only for the pane.
@@ -270,6 +351,7 @@ async function carryOverClear($: Dollar) {
270
351
  isAutoOpened: await read($, paneAutoOpened),
271
352
  viewed: await read($, viewedMap),
272
353
  active: await read($, activeMap),
354
+ isDashboard: await read($, isDashboard),
273
355
  }
274
356
  }
275
357
 
@@ -281,6 +363,7 @@ async function restoreAfterClear($: Dollar) {
281
363
  carried = null
282
364
  try {
283
365
  if (kept?.isAutoOpened === true) await update($, paneAutoOpened, () => true)
366
+ if (kept?.isDashboard === true) await update($, isDashboard, () => true)
284
367
  const active = await readActiveMap($)
285
368
  const viewed = kept?.viewed ?? null
286
369
  if (viewed !== null && viewed !== active && active === kept?.active) await update($, viewedMap, () => viewed)
@@ -323,6 +406,7 @@ async function startWork($: Dollar, number: number) {
323
406
  // A view change is not a repeat read, so the floor does not apply.
324
407
  async function viewMap($: Dollar, number: number | null) {
325
408
  const active = await read($, activeMap)
409
+ await update($, isDashboard, () => false)
326
410
  await update($, viewedMap, () => (number === active ? null : number))
327
411
  void requestRefresh($, { bypassFloor: true })
328
412
  }
@@ -332,6 +416,16 @@ const pressView = ($: Dollar, number: number | null): void => {
332
416
  void viewMap($, number).catch(err => debug($, `trailhead mod: map view failed: ${String(err)}`))
333
417
  }
334
418
 
419
+ // The switch to the dashboard (the Whiteboard row), or back to the map shown before it (the view is kept).
420
+ async function setDashboard($: Dollar, isOn: boolean) {
421
+ await update($, isDashboard, () => isOn)
422
+ void requestRefresh($, { bypassFloor: true })
423
+ }
424
+
425
+ const pressDashboard = ($: Dollar, isOn: boolean): void => {
426
+ void setDashboard($, isOn).catch(err => debug($, `trailhead mod: dashboard switch failed: ${String(err)}`))
427
+ }
428
+
335
429
  // Opening always refreshes. A render cannot write state, not even from work it left running,
336
430
  // so the auto-open runs from a zero-delay timer, a dispatch of its own; `isOpening` keeps two
337
431
  // renders from opening twice before the flag is written.
@@ -447,8 +541,11 @@ export function registerMapPane(on: On) {
447
541
  const active = await read($, activeMap)
448
542
  const viewed = await read($, viewedMap)
449
543
  const target = viewTarget(active, viewed)
544
+ const isBoard = await read($, isDashboard)
545
+ const board = await read($, dashboardPane)
450
546
  const play = await read($, workState)
451
547
  const orphaned = await read($, isOrphaned)
548
+ const own = await read($, ownTicket)
452
549
  const pending = await read($, pendingWork)
453
550
  const now = await $.clock.now()
454
551
  const cols = e.props.bodyColumns
@@ -463,8 +560,9 @@ export function registerMapPane(on: On) {
463
560
  const link = (url: string, label: string) => (url === '' ? label : <Link href={url} label={label} />)
464
561
 
465
562
  // The open maps, one row each; shown with several maps open (or with no active map, to pick one).
563
+ // On the dashboard every map is listed and each row opens it.
466
564
  const mapRow = (m: MapSummary) => {
467
- const r = mapRowLayout(m, { active, viewed, columns: cols })
565
+ const r = mapRowLayout(m, { active, viewed, columns: cols, isDashboard: isBoard })
468
566
  return (
469
567
  <Box key={`map-row-${m.number}`}>
470
568
  {text(`map-mark-${m.number}`, r.isActive ? accent : {}, r.mark)}
@@ -481,13 +579,13 @@ export function registerMapPane(on: On) {
481
579
  </Box>
482
580
  )
483
581
  }
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
582
+ const isMapsShown = isBoard ? data.maps.length > 0 : data.maps.length > 1 || (active === null && data.maps.length > 0)
583
+ const mapsGroup = isMapsShown ? (
584
+ <Box key="group-maps" flexDirection="column" marginTop={gap}>
585
+ {text('head-maps', bold, `Maps · ${data.maps.length}`)}
586
+ {data.maps.map(mapRow)}
587
+ </Box>
588
+ ) : null
491
589
  // Viewing a map other than the active one: say so, with the way back.
492
590
  const viewing =
493
591
  viewed === null || viewed === active ? null : (
@@ -500,58 +598,30 @@ export function registerMapPane(on: On) {
500
598
  />
501
599
  </Box>
502
600
  )
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))}
601
+ // The dashboard's header: its title, and the `map` button back to the map shown before it.
602
+ const headerRow = (title: ReturnType<typeof text>) => (
603
+ <Box key="header-row" width={cols} justifyContent="space-between">
604
+ {title}
605
+ <Button key="to-map" label={MAP_LABEL} onPress={() => pressDashboard($, false)} />
606
+ </Box>
607
+ )
608
+ // The way into the whiteboard: a row above the maps list, its counts beside the name like a map row.
609
+ const wb = whiteboardRowLayout(data.whiteboard, cols)
610
+ const whiteboardRow = (
611
+ <Box key="whiteboard-row" marginTop={gap}>
612
+ {text('whiteboard-mark', {}, wb.mark)}
613
+ <Button key="open-whiteboard" label={wb.label} onPress={() => pressDashboard($, true)} />
614
+ {wb.aside === null ? null : text('whiteboard-aside', dim, wb.aside)}
533
615
  </Box>
534
616
  )
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
617
 
618
+ // This session's own ticket survives a work-state file another session took over; without one, the file decides.
619
+ const isInPlay = (t: PaneTicket) => (own !== null ? t.number === own : play !== null && !orphaned && play.ticket === t.number)
550
620
  const row = (t: PaneTicket, hasMarkColumn: boolean) => {
551
621
  const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, columns: cols })
552
622
  const style = r.isInPlay ? accent : r.isDim ? dim : {}
553
- const holder = r.isInPlay ? { color: ACCENT } : r.isDim ? dim : {}
554
- return (
623
+ const holder = r.isDim ? dim : {}
624
+ const line = (
555
625
  <Box key={`row-${t.number}`} width={cols} justifyContent="space-between">
556
626
  <Box>
557
627
  {r.mark === null ? null : text(`mark-${t.number}`, r.isInPlay ? accent : {}, r.mark)}
@@ -561,6 +631,7 @@ export function registerMapPane(on: On) {
561
631
  {r.type === null ? null : text(`type-${t.number}`, dim, r.type)}
562
632
  </Box>
563
633
  {r.aside?.kind === 'holder' ? text(`holder-${t.number}`, holder, r.aside.text) : null}
634
+ {r.aside?.kind === 'self' ? text(`self-${t.number}`, { color: ACCENT }, r.aside.text) : null}
564
635
  {r.aside?.kind === 'queued' ? text(`queued-${t.number}`, { color: AMBER }, r.aside.text) : null}
565
636
  {r.aside?.kind === 'work' ? (
566
637
  <Button
@@ -572,8 +643,16 @@ export function registerMapPane(on: On) {
572
643
  ) : null}
573
644
  </Box>
574
645
  )
646
+ const parent = uatParentLine(t, cols)
647
+ return parent === null ? (
648
+ line
649
+ ) : (
650
+ <Box key={`entry-${t.number}`} flexDirection="column">
651
+ {line}
652
+ {text(`uat-of-${t.number}`, dim, parent)}
653
+ </Box>
654
+ )
575
655
  }
576
-
577
656
  const group = (key: string, name: string, rows: readonly PaneTicket[]) => {
578
657
  if (rows.length === 0) return null
579
658
  const hasMarkColumn = rows.some(isInPlay)
@@ -589,15 +668,129 @@ export function registerMapPane(on: On) {
589
668
  </Box>
590
669
  )
591
670
  }
671
+ const ticketGroups = (tickets: readonly PaneTicket[]) => {
672
+ const groups = groupTickets(tickets)
673
+ return [
674
+ group('frontier', 'Frontier', groups.frontier),
675
+ group('in-progress', 'In progress', groups.inProgress),
676
+ group('uat', 'UAT', groups.uat),
677
+ group('paused', 'Paused', groups.paused),
678
+ group('blocked', 'Blocked', groups.blocked),
679
+ ]
680
+ }
681
+ const notes = (d: { unverifiedCount: number; isCapped: boolean }) => [
682
+ d.unverifiedCount === 0 ? null : (
683
+ <Box key="unverified" marginTop={gap}>
684
+ <Text {...dim}>{unverifiedLine(d.unverifiedCount)}</Text>
685
+ </Box>
686
+ ),
687
+ !d.isCapped ? null : (
688
+ <Box key="capped" marginTop={gap}>
689
+ <Text {...dim}>{`list capped at ${LIST_LIMIT}`}</Text>
690
+ </Box>
691
+ ),
692
+ ]
693
+ const footerOf = (d: { fetchedAt: number | null; error: string | null }) => {
694
+ const status =
695
+ d.error === null
696
+ ? `updated ${ageLabel(now - (d.fetchedAt ?? now))}`
697
+ : d.fetchedAt === null
698
+ ? `⚠ ${d.error}`
699
+ : `⚠ ${d.error} · list from ${ageLabel(now - d.fetchedAt)}`
700
+ const footer = footerStatus(status, cols)
701
+ return (
702
+ <Box key="footer" flexDirection="column">
703
+ {text('rule', dim, '─'.repeat(cols))}
704
+ <Box key="footer-row" width={cols} justifyContent="space-between">
705
+ <Box gap={1}>
706
+ <Button key="refresh" label="refresh" onPress={() => void requestRefresh($)} />
707
+ {footer.isInline ? text('age', dim, footer.text) : null}
708
+ </Box>
709
+ <Button
710
+ key="close"
711
+ label="close"
712
+ onPress={() => void closePane($).catch(err => debug($, `trailhead mod: close press failed: ${String(err)}`))}
713
+ />
714
+ </Box>
715
+ {footer.isInline ? null : <Box key="age-row">{text('age', dim, footer.text)}</Box>}
716
+ </Box>
717
+ )
718
+ }
719
+ // An empty view (a pane the person opened, or one that waited undrawn) asks for its first read,
720
+ // once. Like the auto-open it leaves the render through a zero-delay timer; inside the floor it
721
+ // waits for the trailing refresh rather than the slow timer.
722
+ const askFill = () => {
723
+ if (isFilling) return
724
+ isFilling = true
725
+ $.clock.after(0, () => {
726
+ isFilling = false
727
+ void requestRefresh($)
728
+ })
729
+ }
730
+
731
+ if (isBoard) {
732
+ const isLoaded = board.fetchedAt !== null
733
+ if (!isLoaded && !board.isLoading && board.error === null) askFill()
734
+ const heading = fit('Dashboard', headingRoom(cols, MAP_LABEL))
735
+ const title = (
736
+ <Box key="dashboard-title">
737
+ <Text {...accent}>{board.dashboard === null ? heading : link(board.dashboard.url, heading)}</Text>
738
+ </Box>
739
+ )
740
+ const isEmpty = isLoaded && board.error === null && board.tickets.length === 0
741
+ return (
742
+ <Box flexDirection="column" width={cols} {...(isDock ? { minHeight: e.props.scroll.bodyRows } : {})}>
743
+ {headerRow(title)}
744
+ {mapsGroup}
745
+ <Box key="whiteboard" marginTop={gap}>
746
+ {text('head-whiteboard', bold, whiteboardHeading(isLoaded ? board.tickets : null, board.isCapped))}
747
+ </Box>
748
+ {!isLoaded && board.error === null ? text('loading', dim, 'Loading dashboard…') : null}
749
+ {ticketGroups(board.tickets)}
750
+ {isEmpty ? text('no-tickets', dim, 'No open whiteboard tickets.') : null}
751
+ {notes(board)}
752
+ <Box key="spacer" flexGrow={1} />
753
+ {footerOf(board)}
754
+ </Box>
755
+ )
756
+ }
757
+
758
+ if (target === null) {
759
+ return (
760
+ <Box flexDirection="column" width={cols}>
761
+ {text('empty', dim, 'No active map. Run /trailhead:map to pick one.')}
762
+ {whiteboardRow}
763
+ {mapsGroup}
764
+ </Box>
765
+ )
766
+ }
767
+ const isCurrent = data.map?.number === target
768
+ const isLoading = !isCurrent && (data.isLoading || data.error === null)
769
+ if (!isCurrent && !data.isLoading && data.error === null) askFill()
592
770
 
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)
771
+ const map = isCurrent ? data.map : null
772
+ const heading = map === null ? `map #${target}` : fit(`#${map.number} ${map.title}`, cols)
773
+ const header = (
774
+ <Box key="header" flexDirection="column">
775
+ <Box key="map">
776
+ <Text {...accent}>{map === null ? heading : link(map.url, heading)}</Text>
777
+ </Box>
778
+ {map?.destination == null ? null : text('destination', dim, fit(map.destination, cols))}
779
+ </Box>
780
+ )
781
+ if (isLoading) {
782
+ return (
783
+ <Box flexDirection="column">
784
+ {header}
785
+ {viewing}
786
+ {text('loading', dim, 'Loading tickets…')}
787
+ {whiteboardRow}
788
+ {mapsGroup}
789
+ </Box>
790
+ )
791
+ }
600
792
 
793
+ const tickets = isCurrent ? data.tickets : []
601
794
  const end = burndownEnd(data, now)
602
795
  const series = isCurrent && data.history !== null ? burndownSeries(data.history, end.at, cols) : null
603
796
  const burndown = (s: BurndownSeries, history: BurndownHistory) => (
@@ -621,42 +814,18 @@ export function registerMapPane(on: On) {
621
814
  <Box flexDirection="column" width={cols} {...(isDock ? { minHeight: e.props.scroll.bodyRows } : {})}>
622
815
  {header}
623
816
  {viewing}
817
+ {whiteboardRow}
624
818
  {mapsGroup}
625
- {group('frontier', 'Frontier', groups.frontier)}
626
- {group('in-progress', 'In progress', groups.inProgress)}
627
- {group('blocked', 'Blocked', groups.blocked)}
819
+ {ticketGroups(tickets)}
628
820
  {isCurrent && data.error === null && tickets.length === 0 ? (
629
821
  <Box key="no-tickets" marginTop={gap}>
630
822
  <Text {...dim}>No open tickets. Fog may remain: /trailhead:map</Text>
631
823
  </Box>
632
824
  ) : 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
- )}
825
+ {isCurrent ? notes(data) : null}
643
826
  {series === null || data.history === null ? null : burndown(series, data.history)}
644
827
  <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>
828
+ {footerOf(data)}
660
829
  </Box>
661
830
  )
662
831
  })
@@ -9,11 +9,13 @@ import type { EngineInterface, Register } from 'claude-code'
9
9
  import type { PromptSnapshot, WorkState } from '../../types'
10
10
  import { registerMapPane } from './map-pane'
11
11
  import { promptSection, repoSlug, snapshotKey } from './work-state-prompt'
12
- import { fit, isMissing, parseWorkState, pipeline, sameValue, statusLine } from './work-state-view'
12
+ import { fit, isMissing, nextOwnTicket, parseWorkState, pipeline, sameValue, statusLine } from './work-state-view'
13
13
  import type { StageStatus } from './work-state-view'
14
14
 
15
15
  export const workState = atom({ plugin: 'trailhead', key: 'workState' } as const, null as WorkState | null)
16
16
  export const isOrphaned = atom({ plugin: 'trailhead', key: 'isOrphaned' } as const, false)
17
+ // The ticket this session works, kept across a work-state file other sessions share.
18
+ export const ownTicket = atom({ plugin: 'trailhead', key: 'ownTicket' } as const, null as number | null)
17
19
  export const promptSnapshot = atom({ plugin: 'trailhead', key: 'promptSnapshot' } as const, null as PromptSnapshot | null)
18
20
 
19
21
  const STATE_PATH = '.trailhead/work-state.json'
@@ -95,16 +97,27 @@ async function apply($: Dollar, state: WorkState | null, orphaned: boolean) {
95
97
  pin($, state, isStale)
96
98
  }
97
99
 
100
+ // Decided from the state read before this refresh replaces it.
101
+ async function setOwnTicket($: Dollar, next: WorkState | null) {
102
+ const prev = await read($, ownTicket)
103
+ const value = nextOwnTicket(prev, await read($, workState), next, await $.session.id())
104
+ if (value !== prev) await update($, ownTicket, () => value)
105
+ }
106
+
98
107
  async function refresh($: Dollar) {
99
108
  let text: string
100
109
  try {
101
110
  text = (await $.fs.read(STATE_PATH)) as string
102
111
  } catch (err) {
103
112
  // A missing file means no ticket in play; any other failure is transient: keep the last state.
104
- if (isMissing(err)) await apply($, null, false)
113
+ if (isMissing(err)) {
114
+ await setOwnTicket($, null)
115
+ await apply($, null, false)
116
+ }
105
117
  return
106
118
  }
107
119
  const state = parseWorkState(text)
120
+ await setOwnTicket($, state)
108
121
  if (state === null) return apply($, null, false)
109
122
  const session = await $.session.id()
110
123
  return apply($, state, state.session !== null && state.session !== session)