@marcomigozzi/trailhead 0.16.1 → 0.18.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 (154) hide show
  1. package/README.it.md +82 -25
  2. package/README.md +82 -25
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/capture-action.test.js +167 -0
  6. package/bin/capture-docs.test.js +32 -0
  7. package/bin/engine-route-docs.test.js +155 -0
  8. package/bin/facet-stamp-docs.test.js +79 -0
  9. package/bin/forced-mutation-docs.test.js +38 -0
  10. package/bin/grill-model-docs.test.js +155 -0
  11. package/bin/grilling-holistic-review-docs.test.js +7 -3
  12. package/bin/guard-axis-docs.test.js +69 -0
  13. package/bin/inbox-needs-info-docs.test.js +68 -0
  14. package/bin/lib/codex-projection.js +28 -19
  15. package/bin/lib/codex-projection.test.js +77 -20
  16. package/bin/lib/host-descriptor.js +1 -1
  17. package/bin/lib/host-descriptor.test.js +6 -0
  18. package/bin/needs-you-docs.test.js +9 -2
  19. package/bin/options-in-chat-docs.test.js +65 -0
  20. package/bin/pick-stress-test-docs.test.js +1 -1
  21. package/bin/pipeline-review-docs.test.js +118 -0
  22. package/bin/plan-review-budget-docs.test.js +63 -0
  23. package/bin/repo-target-docs.test.js +41 -0
  24. package/bin/report-status-docs.test.js +10 -2
  25. package/bin/seam-coverage-docs.test.js +46 -0
  26. package/bin/session-trust-docs.test.js +37 -0
  27. package/bin/standards-brief-docs.test.js +112 -0
  28. package/bin/surfaced-scope-docs.test.js +57 -0
  29. package/bin/test-once-docs.test.js +101 -0
  30. package/bin/test-run-docs.test.js +47 -0
  31. package/bin/trailhead.js +15 -8
  32. package/bin/trailhead.test.js +71 -17
  33. package/bin/unblock-docs.test.js +56 -0
  34. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  35. package/package.json +1 -1
  36. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  37. package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
  38. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  39. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  40. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  41. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  42. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  43. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  44. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  45. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  46. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  47. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  48. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  49. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  50. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  51. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  52. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  53. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  54. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  55. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  56. package/plugins/trailhead/hooks/lib/issue-body.test.js +165 -0
  57. package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
  58. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
  59. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  60. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  61. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  62. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  63. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  64. package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
  65. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  66. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  67. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  68. package/plugins/trailhead/hooks/mod/README.md +42 -17
  69. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  70. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  71. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  72. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  73. package/plugins/trailhead/hooks/mod/map-pane.tsx +204 -49
  74. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  75. package/plugins/trailhead/hooks/mod/repo-pin.ts +113 -9
  76. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  77. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  79. package/plugins/trailhead/hooks/trailhead-check-update.js +20 -2
  80. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  81. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  83. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  84. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  85. package/plugins/trailhead/skills/_shared/capture-core.md +123 -0
  86. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  87. package/plugins/trailhead/skills/_shared/configuration-reference.md +30 -16
  88. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  89. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  90. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  91. package/plugins/trailhead/skills/_shared/substrate-commands.md +19 -6
  92. package/plugins/trailhead/skills/_shared/substrate.md +4 -3
  93. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  94. package/plugins/trailhead/skills/_shared/teamwork.md +11 -5
  95. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  96. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +22 -6
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +6 -3
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +5 -2
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +28 -3
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  103. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  104. package/plugins/trailhead/skills/_shared/techniques/verify.md +5 -1
  105. package/plugins/trailhead/skills/_shared/techniques.md +8 -6
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  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 +11 -6
  111. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +18 -3
  112. package/plugins/trailhead/skills/trailhead-work/SKILL.md +12 -9
  113. package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
  114. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  115. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +13 -13
  116. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  117. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  118. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  119. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  120. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  121. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  122. package/plugins/trailhead/templates/trailhead-capture.js +419 -0
  123. package/plugins/trailhead/templates/trailhead-checkout-find.js +257 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +78 -21
  125. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  126. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  127. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  128. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +161 -12
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  135. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  136. package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
  137. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  138. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  139. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  140. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  141. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  142. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  143. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  144. package/plugins/trailhead/tests/map-pane-ui.test.ts +386 -50
  145. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  146. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  147. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  148. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  149. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  150. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  151. package/plugins/trailhead/tests/unscrub.ts +8 -0
  152. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  153. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
  154. package/plugins/trailhead/types/index.d.ts +7 -0
@@ -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, DashboardPaneData, MapCounts, MapPaneData, MapsRead, MapSummary, OpenTotal, PaneTicket, RepoPin, UatParent, WorkState } from '../../types'
7
+ import type { BurndownHistory, DashboardPaneData, MapCounts, MapPaneData, MapsRead, MapSummary, MyTicket, OpenTotal, PaneTicket, RepoPin, TicketScope, UatParent, WorkState } from '../../types'
8
8
  import {
9
9
  ageLabel,
10
10
  axisStart,
@@ -48,7 +48,10 @@ import {
48
48
  whiteboardRowLayout,
49
49
  withLabel,
50
50
  } from './map-pane-view'
51
+ import { elsewhereInProgress, myTicketsFromRows, parseLogin, scopeLine } from './my-tickets'
51
52
  import { checkoutArg, currentRepo, isPinCheckout, isSamePin, pinKey, pinPath, refreshPin, toplevelOf } from './repo-pin'
53
+ import { gitArgv, scrubGitEnv } from './git-env'
54
+ import { stripHeredocBodies } from './heredoc'
52
55
  import { isMainTurnRunning } from './turn-state'
53
56
  import { createdIssueNumber, isWhiteboardCreate, nextFollow, whiteboardHint } from './whiteboard-follow'
54
57
  import { fit, isMissing, sameValue } from './work-state-view'
@@ -79,6 +82,9 @@ const TICKETS_QUERY = `query($owner:String!,$repo:String!,$label:String!,$after:
79
82
  // most UAT_MAX_PAGES pages). The scope is filtered by the caller: GraphQL's `labels` is any-of.
80
83
  const UAT_QUERY = `query($owner:String!,$repo:String!,$after:String){repository(owner:$owner,name:$repo){issues(first:${PAGE_SIZE},after:$after,states:OPEN,labels:["trailhead:uat"],orderBy:{field:CREATED_AT,direction:DESC}){nodes{number body labels(first:50){nodes{name}}}pageInfo{hasNextPage endCursor}}}}`
81
84
  const UAT_MAX_PAGES = 10
85
+ // The gh account's login, then its open tickets on any map or the whiteboard (paged like TICKETS_QUERY).
86
+ const LOGIN_QUERY = 'query{viewer{login}}'
87
+ const MINE_QUERY = `query($owner:String!,$repo:String!,$login:String!,$after:String){repository(owner:$owner,name:$repo){issues(first:${PAGE_SIZE},after:$after,states:OPEN,labels:["trailhead:ticket"],filterBy:{assignee:$login},orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url labels(first:50){nodes{name}}assignees(first:10){nodes{login}}}pageInfo{hasNextPage endCursor}}}}`
82
88
  const DASHBOARD_FIELDS = 'number,url'
83
89
  // Same colours as the band.
84
90
  const ACCENT = '#7aa2f7'
@@ -88,6 +94,24 @@ const CHART_ROWS = 3
88
94
  // Space between ticket rows on the desktop, whose native buttons would touch; the terminal stays compact.
89
95
  const DESKTOP_ROW_GAP = 0.5
90
96
  const AMBER = '#e0af68'
97
+ // Ogni sezione ha il suo glifo e colore (palette Tokyo Night del band), così si distinguono anche senza colore.
98
+ const SECTION_STYLES = {
99
+ maps: { glyph: '◈', color: '#bb9af7' },
100
+ 'in-progress': { glyph: '▶', color: ACCENT },
101
+ 'needs-you': { glyph: '!', color: '#ff9e64' },
102
+ frontier: { glyph: '◆', color: '#9ece6a' },
103
+ uat: { glyph: '◎', color: '#7dcfff' },
104
+ paused: { glyph: '‖', color: '#a9b1d6' },
105
+ blocked: { glyph: '■', color: '#f7768e' },
106
+ } as const
107
+ type SectionKey = keyof typeof SECTION_STYLES
108
+ // Il filetto a sinistra di ogni riga, nel colore della sezione: occupa GUTTER colonne.
109
+ const GUTTER = '│ '
110
+ // Sul desktop il filetto è una barra continua di questa larghezza, alta quanto tutto il blocco del ticket.
111
+ const GUTTER_BAR = 0.25
112
+ const RULE_COLOR = '#3b4261'
113
+ // Il tipo di ticket colorato; gli altri tipi restano attenuati.
114
+ const TYPE_COLORS: Readonly<Record<string, string>> = { build: '#7dcfff', decision: '#ff9e64' }
91
115
 
92
116
  const EMPTY: MapPaneData = {
93
117
  map: null,
@@ -123,10 +147,13 @@ export const viewedMap = atom({ plugin: 'trailhead', key: 'viewedMap' } as const
123
147
  export const isDashboard = atom({ plugin: 'trailhead', key: 'isDashboard' } as const, false)
124
148
  export const dashboardPane = atom({ plugin: 'trailhead', key: 'dashboardPane' } as const, EMPTY_DASHBOARD)
125
149
  // The ticket whose `work` or `resume` press is queued; one at a time.
150
+ // My open tickets across every map and the whiteboard (the last good read), for every view's In progress group.
151
+ export const myTickets = atom({ plugin: 'trailhead', key: 'myTickets' } as const, [] as readonly MyTicket[])
126
152
  export const pendingWork = atom({ plugin: 'trailhead', key: 'pendingWork' } as const, null as number | null)
127
153
  // Whether that press waits for the running turn to end (cancellable) or was sent to the host.
128
154
  export const pendingPhase = atom({ plugin: 'trailhead', key: 'pendingPhase' } as const, null as 'queued' | 'starting' | null)
129
- // Set once the pane was opened by the command, by itself or closed by the person: never auto-opened after.
155
+ // Set once the auto-open is done: the pane was seen drawn, or the person acted on it (the command, a close by
156
+ // hand, the close button). An unasked open that stays undrawn does not set it; never auto-opened after it is set.
130
157
  export const paneAutoOpened = atom({ plugin: 'trailhead', key: 'paneAutoOpened' } as const, false)
131
158
  const PENDING = { plugin: 'trailhead', key: 'pendingWork' } as const
132
159
  const WORK_COMMAND = 'trailhead:work'
@@ -207,12 +234,15 @@ async function setPane($: Dollar, next: MapPaneData) {
207
234
  type GhResult = { ok: true; stdout: string } | { ok: false; reason: string }
208
235
 
209
236
  // Always run in the pinned repo, so the session's cwd moving out of it never changes what is read.
237
+ // gh resolves {owner}/{repo} through git, so it gets the same scrubbed env as a git call.
210
238
  async function gh($: Dollar, pin: RepoPin, args: readonly string[]): Promise<GhResult> {
211
239
  try {
212
- const out = await $.process.run(['gh', ...args], { cwd: pin.root, timeoutMs: GH_TIMEOUT_MS })
240
+ const out = await $.process.run(scrubGitEnv(['gh', ...args]), { cwd: pin.root, timeoutMs: GH_TIMEOUT_MS })
213
241
  return out.exitCode === 0 ? { ok: true, stdout: out.stdout } : { ok: false, reason: failureReason(out) }
214
242
  } catch (error) {
215
- return { ok: false, reason: failureReason({ error }) }
243
+ // A deleted cwd fails the spawn as ENOENT, the same as a missing binary: look before blaming gh.
244
+ const missingRoot = (await $.fs.exists(pin.root).catch(() => true)) ? undefined : pin.root
245
+ return { ok: false, reason: failureReason({ error, missingRoot }) }
216
246
  }
217
247
  }
218
248
 
@@ -249,6 +279,40 @@ const readOpen = async ($: Dollar, pin: RepoPin, label: string, also?: string):
249
279
  const readOpenFallback = ($: Dollar, pin: RepoPin, label: string, also?: string) =>
250
280
  gh($, pin, ['issue', 'list', ...(also === undefined ? [] : ['--label', also]), '--label', label, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS])
251
281
 
282
+ // The gh login per host, read once: the account does not change under a running session.
283
+ const loginByHost = new Map<string, string>()
284
+ const readLogin = async ($: Dollar, pin: RepoPin): Promise<string | null> => {
285
+ const known = loginByHost.get(pin.host)
286
+ if (known !== undefined) return known
287
+ const out = await gh($, pin, ['api', 'graphql', '-f', `query=${LOGIN_QUERY}`])
288
+ const login = out.ok ? parseLogin(out.stdout) : null
289
+ if (login !== null) loginByHost.set(pin.host, login)
290
+ return login
291
+ }
292
+
293
+ // My open tickets in the repo, on any open map or the whiteboard. Fail-closed like the UAT read: a login or a page
294
+ // that fails or is unreadable makes the read null, and the last good list stays; never a partial list.
295
+ const readMine = async ($: Dollar, pin: RepoPin): Promise<readonly MyTicket[] | null> => {
296
+ const login = await readLogin($, pin)
297
+ if (login === null) return null
298
+ const rows: unknown[] = []
299
+ let cursor: string | null = null
300
+ while (rows.length < LIST_LIMIT) {
301
+ const args = ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MINE_QUERY}`, '-f', `login=${login}`]
302
+ const out = await gh($, pin, cursor === null ? args : [...args, '-f', `after=${cursor}`])
303
+ const page = out.ok ? parseIssuesPage(out.stdout) : null
304
+ if (page === null) return null
305
+ rows.push(...page.rows)
306
+ if (!page.hasNextPage) break
307
+ cursor = page.endCursor
308
+ }
309
+ return myTicketsFromRows(rows)
310
+ }
311
+
312
+ const keepMine = async ($: Dollar, mine: readonly MyTicket[] | null) => {
313
+ if (mine !== null && !sameValue(await read($, myTickets), mine)) await update($, myTickets, () => mine)
314
+ }
315
+
252
316
  // The pending UAT tickets of one scope (a map label, or the whiteboard) with their bodies, for the parent each
253
317
  // one tests. Read through GraphQL (the search index lags a write by seconds, so a follow-up just opened would
254
318
  // show without its parent line), all open `trailhead:uat` tickets, the scope filtered here. Fail-closed: a page
@@ -341,12 +405,13 @@ async function fetchMap($: Dollar, pin: RepoPin, token: number, moves: number, w
341
405
  await setPane($, { ...EMPTY, maps: before.maps, whiteboard: before.whiteboard, openTotal: before.openTotal, isLoading: true })
342
406
  }
343
407
 
344
- const [view, list, historyRead, mapsRead, uatParents] = await readList($, pin, target, before.openTotal)
408
+ const [[view, list, historyRead, mapsRead, uatParents], mine] = await Promise.all([readList($, pin, target, before.openTotal), readMine($, pin)])
345
409
  const maps = mapsRead.maps ?? before.maps
346
410
  const { whiteboard, openTotal } = mapsRead
347
411
  await dropClosedView($, mapsRead.maps)
348
412
  const now = await $.clock.now()
349
413
  if (await isStale()) return
414
+ await keepMine($, mine)
350
415
 
351
416
  const kept = isSameMap ? before : EMPTY
352
417
  const failure = !view.ok ? view.reason : !list.ok ? list.reason : null
@@ -381,14 +446,16 @@ async function fetchDashboard($: Dollar, pin: RepoPin, token: number, moves: num
381
446
  if (before.fetchedAt === null && !before.isLoading && before.error === null) {
382
447
  await update($, dashboardPane, () => ({ ...before, isLoading: true }))
383
448
  }
384
- const [list, index, mapsRead, uatParents] = await Promise.all([
449
+ const [list, index, mapsRead, uatParents, mine] = await Promise.all([
385
450
  readOpen($, pin, 'trailhead:whiteboard', 'trailhead:ticket'),
386
451
  gh($, pin, ['issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', DASHBOARD_FIELDS]),
387
452
  readMaps($, pin, keptTotal),
388
453
  readUat($, pin, 'trailhead:whiteboard'),
454
+ readMine($, pin),
389
455
  ])
390
456
  const now = await $.clock.now()
391
457
  if (await isStale()) return
458
+ await keepMine($, mine)
392
459
  const shown = await read($, mapPane)
393
460
  await setPane($, { ...shown, maps: mapsRead.maps ?? shown.maps, whiteboard: mapsRead.whiteboard, openTotal: mapsRead.openTotal })
394
461
  await dropClosedView($, mapsRead.maps)
@@ -420,6 +487,7 @@ export async function onPinMove($: Dollar, pin: RepoPin | null): Promise<void> {
420
487
  if (!isOtherRepo) return
421
488
  await update($, mapPane, () => EMPTY)
422
489
  await update($, dashboardPane, () => EMPTY_DASHBOARD)
490
+ await update($, myTickets, () => [])
423
491
  await update($, activeMap, () => null)
424
492
  await update($, viewedMap, () => null)
425
493
  await update($, isDashboard, () => false)
@@ -556,6 +624,10 @@ async function restoreAfterClear($: Dollar) {
556
624
  export const GH_ISSUE_WRITE =
557
625
  /\bgh\b[^\n;&|]*\bissue\s+(?:edit|close|create|comment|reopen|delete|transfer)\b|\bgh\b(?=[^\n;&|]*\sapi\s)(?=[^\n;&|]*\brepos\/[^\s;&|]*\/issues\b)(?=[^\n;&|]*(?:\s(?:-X|--method)[\s=]*["']?(?:POST|PATCH|PUT|DELETE)\b|\s(?:-[fF]|--field\b|--raw-field\b|--input\b)))(?![^\n;&|]*\s(?:-X|--method)[\s=]*["']?GET\b)|\btrailhead-issue-body\.js\b/
558
626
 
627
+ // A tracker write once heredoc bodies are out: a body line is data, never a command (#369). The matcher
628
+ // stays the regex because the host's `command:` takes no function; the handler runs this check first.
629
+ export const isTrackerWrite = (command: string): boolean => GH_ISSUE_WRITE.test(stripHeredocBodies(command))
630
+
559
631
  // The host runs the verb in the session's own directory, which a shell `cd` may have moved, and the mod cannot
560
632
  // move it back: `out` hands the pinned checkout to the verb, which moves first (#312). A checkout that cannot
561
633
  // be read is `unknown`, refused, but not told it is elsewhere.
@@ -582,7 +654,7 @@ async function placeOf($: Dollar): Promise<{ pin: RepoPin; where: 'in' | 'out' }
582
654
  // Whether the pinned root still answers as a checkout root, the same test the verb's helper applies.
583
655
  async function isCheckoutRoot($: Dollar, root: string): Promise<boolean> {
584
656
  try {
585
- const out = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: root, timeoutMs: 5_000 })
657
+ const out = await $.process.run(gitArgv('rev-parse', '--show-toplevel'), { cwd: root, timeoutMs: 5_000 })
586
658
  return out.exitCode === 0 && out.stdout.trim() === root
587
659
  } catch {
588
660
  return false
@@ -825,12 +897,17 @@ const pressDashboard = ($: Dollar, isOn: boolean): void => {
825
897
  // so the auto-open runs from a zero-delay timer, a dispatch of its own; `isOpening` keeps two
826
898
  // renders from opening twice before the flag is written.
827
899
  let isOpening = false
900
+ // Quando l'auto-open ha disegnato il pannello: serve a `/trailhead:panel` per riconoscerne uno mai visto.
901
+ let autoOpenedAt: number | null = null
828
902
  // Same for the fill an empty pane asks for from its render.
829
903
  let isFilling = false
830
904
 
831
- async function openPane($: Dollar) {
832
- await update($, paneAutoOpened, () => true)
905
+ // L'auto-open conta come fatto (`paneAutoOpened`) solo quando il pannello è davvero disegnato, o quando la
906
+ // persona è intervenuta (comando, chiusura a mano, bottone close). Un'apertura non richiesta che resta non
907
+ // disegnata non brucia il flag: un trigger successivo (render in fullscreen, `session.attach`) può riprovare.
908
+ async function openPane($: Dollar, isRequested = false) {
833
909
  const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
910
+ if (isRequested || opened.isPlaced) await update($, paneAutoOpened, () => true)
834
911
  if (opened.isPlaced) void requestRefresh($)
835
912
  return opened
836
913
  }
@@ -839,9 +916,14 @@ async function autoOpen($: Dollar) {
839
916
  if (isOpening) return
840
917
  isOpening = true
841
918
  try {
842
- if (!(await $.ui.panes()).some(pane => pane.id === PANE_ID)) await openPane($)
843
- // Found up already (a reload with the pane showing): nothing to open, and renders stop asking.
844
- else await update($, paneAutoOpened, () => true)
919
+ const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
920
+ if (pane === undefined) {
921
+ const opened = await openPane($)
922
+ if (opened.isPlaced) autoOpenedAt = await $.clock.now()
923
+ }
924
+ // Found up and drawn (a reload with the pane showing): nothing to open, and renders stop asking.
925
+ else if (pane.isPlaced) await update($, paneAutoOpened, () => true)
926
+ // Listed but undrawn: the host places it itself when it can; its own render then completes the auto-open.
845
927
  } catch (err) {
846
928
  debug($, `trailhead mod: map pane auto-open failed: ${String(err)}`)
847
929
  } finally {
@@ -849,21 +931,43 @@ async function autoOpen($: Dollar) {
849
931
  }
850
932
  }
851
933
 
852
- // Un pannello aperto ma non disegnato (l'auto-open sotto la soglia dei pannelli non richiesti, tipico di
853
- // una sessione appena aperta) non è visibile: il comando lo riapre, e una richiesta della persona lo
854
- // disegna a qualsiasi larghezza. Se nemmeno così viene disegnato (nessuna superficie), lo chiude.
934
+ // Una lettura sola, da un timer a ritardo zero: un render non può scrivere stato.
935
+ async function autoOpenIfDue($: Dollar) {
936
+ if ((await read($, activeMap)) !== null && !(await read($, paneAutoOpened))) $.clock.after(0, () => void autoOpen($))
937
+ }
938
+
939
+ // Il render del pannello scatta solo da disegnato: l'auto-open è completato.
940
+ async function markDrawn($: Dollar) {
941
+ if (!(await read($, paneAutoOpened))) $.clock.after(0, () => void update($, paneAutoOpened, () => true).catch(err => debug($, `trailhead mod: auto-open flag not set: ${String(err)}`)))
942
+ }
943
+
944
+ // Un pannello aperto ma non disegnato (l'auto-open sotto la soglia dei pannelli non richiesti, o su una
945
+ // superficie che non ha ancora comunicato il layout) non è visibile: il comando lo riapre, e una richiesta
946
+ // della persona lo disegna a qualsiasi larghezza. Se nemmeno così viene disegnato NON lo chiude: la persona
947
+ // non l'ha mai visto, chiuderlo risponderebbe "closed" a un pannello mai mostrato. Resta aperto e viene
948
+ // disegnato appena la superficie può collocarlo. Chiude solo un pannello elencato E disegnato. Senza
949
+ // superficie collegata (`no-surface`, l'inizio sessione) la risposta è l'attesa normale.
950
+ const PENDING_MESSAGE = 'Map panel will open as soon as the screen is ready.'
951
+ const NOT_SHOWN_MESSAGE = 'Map panel is open but not shown yet: it appears once the screen can place it.'
952
+ const isNoSurface = (reason: string) => reason.startsWith('no-surface')
953
+
954
+ // Il desktop avvia la sessione al primo invio, non al +: un `/trailhead:panel` come primo messaggio arriva
955
+ // pochi millisecondi dopo l'auto-open. Nessuno fa in tempo a vedere il pannello e a digitare il comando sotto
956
+ // questa soglia, quindi la richiesta è di vederlo, non di chiuderlo.
957
+ const UNSEEN_MS = 2_000
958
+
855
959
  async function togglePanel($: Dollar): Promise<string> {
856
960
  const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
857
- if (pane?.isPlaced === false) {
858
- const reopened = await openPane($)
859
- if (reopened.isPlaced) return 'Map panel opened.'
860
- }
861
- if (pane !== undefined) {
961
+ const isUnseen = autoOpenedAt !== null && (await $.clock.now()) - autoOpenedAt < UNSEEN_MS
962
+ autoOpenedAt = null
963
+ if (pane?.isPlaced === true && !isUnseen) {
862
964
  await $.ui.close({ id: PANE_ID })
863
965
  return 'Map panel closed.'
864
966
  }
865
- const opened = await openPane($)
866
- return opened.isPlaced ? 'Map panel opened.' : `Map panel not shown: ${opened.reason}`
967
+ const opened = await openPane($, true)
968
+ if (opened.isPlaced) return 'Map panel opened.'
969
+ debug($, `trailhead mod: map pane not placed: ${opened.reason}`)
970
+ return isNoSurface(opened.reason) ? PENDING_MESSAGE : NOT_SHOWN_MESSAGE
867
971
  }
868
972
 
869
973
  // The close button: the pane goes (its `ui.close` hook stops the timers). The press counts as the
@@ -919,12 +1023,14 @@ export function registerMapPane(on: On) {
919
1023
  return result
920
1024
  })
921
1025
 
922
- // After the call ran: a Bash `gh issue` write changed the tracker.
1026
+ // After the call ran: a Bash `gh issue` write changed the tracker (heredoc bodies not counted).
923
1027
  on('tool.call', { tool: 'Bash', command: GH_ISSUE_WRITE }, async ($, e, next) => {
1028
+ // The matcher saw the raw text: a write named only inside a heredoc body is no write.
1029
+ if (typeof e.command !== 'string' || !isTrackerWrite(e.command)) return next(e)
924
1030
  const result = await next(e)
925
1031
  lastWriteAt = await $.clock.now()
926
1032
  void requestRefresh($, { isWrite: true })
927
- await hintWhiteboard($, e.command, result)
1033
+ await hintWhiteboard($, stripHeredocBodies(e.command), result)
928
1034
  return result
929
1035
  // This hook never judges the call: if it fails, the call goes on untouched.
930
1036
  }).catch((_$, e, next) => next(e))
@@ -935,7 +1041,14 @@ export function registerMapPane(on: On) {
935
1041
  // Only in fullscreen, where a pane docks beside the transcript; this bare matcher is
936
1042
  // narrower than the band's, and the band's own hook (in register.tsx) draws after it.
937
1043
  on('ui.render', { component: 'AbovePrompt', viewport: { isFullscreen: true } }, async ($, e, next) => {
938
- if ((await read($, activeMap)) !== null && !(await read($, paneAutoOpened))) $.clock.after(0, () => void autoOpen($))
1044
+ await autoOpenIfDue($)
1045
+ return next(e)
1046
+ })
1047
+
1048
+ // A surface that attaches later (a fresh desktop session) may place the pane the first open could not: with
1049
+ // its layout saying fullscreen, the auto-open tries again. Without a viewport the render trigger decides.
1050
+ on('session.attach', { viewport: { isFullscreen: true } }, async ($, e, next) => {
1051
+ await autoOpenIfDue($)
939
1052
  return next(e)
940
1053
  })
941
1054
 
@@ -948,6 +1061,7 @@ export function registerMapPane(on: On) {
948
1061
  })
949
1062
 
950
1063
  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
1064
+ await markDrawn($)
951
1065
  const ui = $.ui.resolve(e)
952
1066
  const { Box, Text, Button, Link } = ui
953
1067
  // Raster is the terminal surface's only; elsewhere the constructor is not a real element.
@@ -961,6 +1075,7 @@ export function registerMapPane(on: On) {
961
1075
  const play = await read($, workState)
962
1076
  const orphaned = await read($, isOrphaned)
963
1077
  const own = await read($, ownTicket)
1078
+ const mine = await read($, myTickets)
964
1079
  const pin = await read($, repoPin)
965
1080
  const pending = await read($, pendingWork)
966
1081
  const phase = await read($, pendingPhase)
@@ -976,14 +1091,45 @@ export function registerMapPane(on: On) {
976
1091
  </Box>
977
1092
  )
978
1093
  const link = (url: string, label: string) => (url === '' ? label : <Link href={url} label={label} />)
1094
+ // Il filetto di un blocco (riga e righe sotto): sul desktop una barra che si allunga quanto il blocco, nel
1095
+ // terminale un `│` per riga. Occupa sempre GUTTER colonne.
1096
+ const gutter = (key: string, section: SectionKey, lines: number) => {
1097
+ const { color } = SECTION_STYLES[section]
1098
+ return e.surface === 'desktop' ? (
1099
+ <Box key={key} width={GUTTER.length}>
1100
+ <Box key={`${key}-bar`} width={GUTTER_BAR} backgroundColor={color} />
1101
+ </Box>
1102
+ ) : (
1103
+ <Box key={key} flexDirection="column" width={GUTTER.length}>
1104
+ {Array.from({ length: lines }, (_, i) => (
1105
+ <Text key={`${key}-${i}`} color={color}>
1106
+ {GUTTER}
1107
+ </Text>
1108
+ ))}
1109
+ </Box>
1110
+ )
1111
+ }
1112
+ // Intestazione di sezione: glifo e nome nel colore della sezione, poi un filetto fino al bordo.
1113
+ const sectionHead = (key: SectionKey, label: string) => {
1114
+ const { glyph, color } = SECTION_STYLES[key]
1115
+ const rest = cols - glyph.length - 1 - label.length - 1
1116
+ return (
1117
+ <Box key={`section-head-${key}`}>
1118
+ {text(`glyph-${key}`, { color }, `${glyph} `)}
1119
+ {text(`head-${key}`, { bold: true, color }, label)}
1120
+ {rest > 0 ? text(`rule-${key}`, { color: RULE_COLOR }, ` ${'─'.repeat(rest)}`) : null}
1121
+ </Box>
1122
+ )
1123
+ }
979
1124
 
980
1125
  // The open maps, one row each; shown with several maps open (or with no active map, to pick one).
981
1126
  // On the dashboard every map is listed and each row opens it.
982
1127
  const mapRow = (m: MapSummary) => {
983
- const r = mapRowLayout(m, { active, viewed, columns: cols, isDashboard: isBoard })
1128
+ const r = mapRowLayout(m, { active, viewed, columns: cols - GUTTER.length, isDashboard: isBoard })
984
1129
  return (
985
1130
  <Box key={`map-row-${m.number}`}>
986
- {text(`map-mark-${m.number}`, r.isActive ? accent : {}, r.mark)}
1131
+ {gutter(`map-gutter-${m.number}`, 'maps', 1)}
1132
+ {text(`map-mark-${m.number}`, r.isActive ? { bold: true, color: SECTION_STYLES.maps.color } : {}, r.mark)}
987
1133
  {r.isPressable ? (
988
1134
  <Button
989
1135
  key={`map-${m.number}`}
@@ -1000,7 +1146,7 @@ export function registerMapPane(on: On) {
1000
1146
  const isMapsShown = isBoard ? data.maps.length > 0 : data.maps.length > 1 || (active === null && data.maps.length > 0)
1001
1147
  const mapsGroup = isMapsShown ? (
1002
1148
  <Box key="group-maps" flexDirection="column" marginTop={gap}>
1003
- {text('head-maps', bold, `Maps · ${data.maps.length}`)}
1149
+ {sectionHead('maps', `Maps · ${data.maps.length}`)}
1004
1150
  {data.maps.map(mapRow)}
1005
1151
  </Box>
1006
1152
  ) : null
@@ -1039,18 +1185,20 @@ export function registerMapPane(on: On) {
1039
1185
 
1040
1186
  // This session's own ticket survives a work-state file another session took over; without one, the file decides.
1041
1187
  const isInPlay = (t: PaneTicket) => (own !== null ? t.number === own : play !== null && !orphaned && play.ticket === t.number)
1042
- const row = (t: PaneTicket, hasMarkColumn: boolean) => {
1043
- const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, pendingPhase: phase, columns: cols })
1188
+ const row = (t: PaneTicket, section: SectionKey, hasMarkColumn: boolean, scope: string | null = null) => {
1189
+ const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, pendingPhase: phase, columns: cols - GUTTER.length })
1044
1190
  const style = r.isInPlay ? accent : r.isDim ? dim : {}
1045
1191
  const holder = r.isDim ? dim : {}
1192
+ const typeColor = t.type === null ? undefined : TYPE_COLORS[t.type]
1193
+ const typeStyle = r.isDim || typeColor === undefined ? dim : { color: typeColor }
1046
1194
  const line = (
1047
- <Box key={`row-${t.number}`} width={cols} justifyContent="space-between">
1195
+ <Box key={`row-${t.number}`} width={cols - GUTTER.length} justifyContent="space-between">
1048
1196
  <Box>
1049
1197
  {r.mark === null ? null : text(`mark-${t.number}`, r.isInPlay ? accent : {}, r.mark)}
1050
1198
  <Box key={`ticket-${t.number}`}>
1051
1199
  <Text {...style}>{link(t.url, r.label)}</Text>
1052
1200
  </Box>
1053
- {r.type === null ? null : text(`type-${t.number}`, dim, r.type)}
1201
+ {r.type === null ? null : text(`type-${t.number}`, typeStyle, r.type)}
1054
1202
  </Box>
1055
1203
  {r.aside?.kind === 'holder' ? text(`holder-${t.number}`, holder, r.aside.text) : null}
1056
1204
  {r.aside?.kind === 'self' ? text(`self-${t.number}`, { color: ACCENT }, r.aside.text) : null}
@@ -1078,19 +1226,24 @@ export function registerMapPane(on: On) {
1078
1226
  ) : null}
1079
1227
  </Box>
1080
1228
  )
1081
- const parent = uatParentLine(t, cols)
1082
- return parent === null ? (
1083
- line
1084
- ) : (
1085
- <Box key={`entry-${t.number}`} flexDirection="column">
1086
- {line}
1087
- {text(`uat-of-${t.number}`, dim, parent)}
1229
+ // Le righe sotto (genitore UAT, dove sta) rientrano dopo il filetto, che copre tutto il blocco.
1230
+ const parent = uatParentLine(t, cols - GUTTER.length)
1231
+ const lines = 1 + (parent === null ? 0 : 1) + (scope === null ? 0 : 1)
1232
+ return (
1233
+ <Box key={`entry-${t.number}`} width={cols}>
1234
+ {gutter(`gutter-${t.number}`, section, lines)}
1235
+ <Box key={`entry-lines-${t.number}`} flexDirection="column">
1236
+ {line}
1237
+ {parent === null ? null : text(`uat-of-${t.number}`, dim, parent)}
1238
+ {scope === null ? null : text(`scope-of-${t.number}`, dim, scope)}
1239
+ </Box>
1088
1240
  </Box>
1089
1241
  )
1090
1242
  }
1091
- const group = (key: string, name: string, rows: readonly PaneTicket[]) => {
1092
- if (rows.length === 0) return null
1093
- const hasMarkColumn = rows.some(isInPlay)
1243
+ // `elsewhere`: my tickets of other scopes, listed after the shown scope's with the line naming where each is.
1244
+ const group = (key: Exclude<SectionKey, 'maps'>, name: string, rows: readonly PaneTicket[], elsewhere: readonly MyTicket[] = []) => {
1245
+ if (rows.length + elsewhere.length === 0) return null
1246
+ const hasMarkColumn = [...rows, ...elsewhere].some(isInPlay)
1094
1247
  return (
1095
1248
  <Box
1096
1249
  key={`group-${key}`}
@@ -1098,17 +1251,19 @@ export function registerMapPane(on: On) {
1098
1251
  marginTop={gap}
1099
1252
  {...(e.surface === 'desktop' ? { rowGap: DESKTOP_ROW_GAP } : {})}
1100
1253
  >
1101
- {text(`head-${key}`, bold, `${name} · ${rows.length}`)}
1102
- {rows.map(t => row(t, hasMarkColumn))}
1254
+ {sectionHead(key, `${name} · ${rows.length + elsewhere.length}`)}
1255
+ {rows.map(t => row(t, key, hasMarkColumn))}
1256
+ {elsewhere.map(t => row(t, key, hasMarkColumn, scopeLine(t, data.maps, cols - GUTTER.length)))}
1103
1257
  </Box>
1104
1258
  )
1105
1259
  }
1106
- const ticketGroups = (tickets: readonly PaneTicket[]) => {
1260
+ const ticketGroups = (tickets: readonly PaneTicket[], here: TicketScope) => {
1107
1261
  const groups = groupTickets(tickets)
1262
+ // In progress first, right under the maps list: what I already hold comes before what to pick up.
1108
1263
  return [
1264
+ group('in-progress', 'In progress', groups.inProgress, elsewhereInProgress(mine, here, tickets)),
1109
1265
  group('needs-you', 'Needs you', groups.needsYou),
1110
1266
  group('frontier', 'Frontier', groups.frontier),
1111
- group('in-progress', 'In progress', groups.inProgress),
1112
1267
  group('uat', 'UAT', groups.uat),
1113
1268
  group('paused', 'Paused', groups.paused),
1114
1269
  group('blocked', 'Blocked', groups.blocked),
@@ -1117,7 +1272,7 @@ export function registerMapPane(on: On) {
1117
1272
  const notes = (d: { unverifiedCount: number; isCapped: boolean }) => [
1118
1273
  d.unverifiedCount === 0 ? null : (
1119
1274
  <Box key="unverified" marginTop={gap}>
1120
- <Text {...dim}>{unverifiedLine(d.unverifiedCount)}</Text>
1275
+ <Text color={AMBER}>{unverifiedLine(d.unverifiedCount)}</Text>
1121
1276
  </Box>
1122
1277
  ),
1123
1278
  !d.isCapped ? null : (
@@ -1201,7 +1356,7 @@ export function registerMapPane(on: On) {
1201
1356
  {text('head-whiteboard', bold, whiteboardHeading(isLoaded ? board.tickets : null, board.isCapped))}
1202
1357
  </Box>
1203
1358
  {!isLoaded && board.error === null ? text('loading', dim, 'Loading dashboard…') : null}
1204
- {ticketGroups(board.tickets)}
1359
+ {ticketGroups(board.tickets, { kind: 'whiteboard' })}
1205
1360
  {isEmpty ? text('no-tickets', dim, 'No open whiteboard tickets.') : null}
1206
1361
  {notes(board)}
1207
1362
  <Box key="spacer" flexGrow={1} />
@@ -1274,7 +1429,7 @@ export function registerMapPane(on: On) {
1274
1429
  {whiteboardRow}
1275
1430
  {totalLine}
1276
1431
  {mapsGroup}
1277
- {ticketGroups(tickets)}
1432
+ {ticketGroups(tickets, { kind: 'map', number: target })}
1278
1433
  {isCurrent && data.error === null && tickets.length === 0 ? (
1279
1434
  <Box key="no-tickets" marginTop={gap}>
1280
1435
  <Text {...dim}>No open tickets. Fog may remain: /trailhead:map</Text>
@@ -0,0 +1,76 @@
1
+ // My tickets in play across the repo: the open tickets assigned to the gh login, on any open map or on the
2
+ // whiteboard, so every view's In progress group shows them, not only the shown scope's. Pure, easy to test.
3
+ import type { MapSummary, MyTicket, PaneTicket, TicketScope } from '../../types'
4
+ import { groupTickets, ticketsFromRows } from './map-pane-view'
5
+ import { fit } from './work-state-view'
6
+
7
+ const MAP_LABEL = /^trailhead:map-(\d+)$/
8
+ const WHITEBOARD_LABEL = 'trailhead:whiteboard'
9
+
10
+ const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
11
+
12
+ const namesOf = (row: unknown): string[] =>
13
+ isRecord(row) && Array.isArray(row.labels)
14
+ ? row.labels.flatMap(l => (isRecord(l) && typeof l.name === 'string' ? [l.name] : []))
15
+ : []
16
+
17
+ // One map label, or the whiteboard label alone; none, or contradictory labels, is no scope (never guessed).
18
+ const scopeOf = (names: readonly string[]): TicketScope | null => {
19
+ const maps = names.flatMap(n => {
20
+ const m = MAP_LABEL.exec(n)
21
+ return m === null ? [] : [Number(m[1])]
22
+ })
23
+ const isBoard = names.includes(WHITEBOARD_LABEL)
24
+ if (isBoard) return maps.length === 0 ? { kind: 'whiteboard' } : null
25
+ return maps.length === 1 && Number.isInteger(maps[0]) && (maps[0] as number) > 0 ? { kind: 'map', number: maps[0] as number } : null
26
+ }
27
+
28
+ // `{data:{viewer:{login}}}` as the login; any error or an empty login is unreadable (null).
29
+ export const parseLogin = (stdout: string): string | null => {
30
+ let raw: unknown
31
+ try {
32
+ raw = JSON.parse(stdout)
33
+ } catch {
34
+ return null
35
+ }
36
+ if (!isRecord(raw) || 'errors' in raw || !isRecord(raw.data) || !isRecord(raw.data.viewer)) return null
37
+ const { login } = raw.data.viewer
38
+ return typeof login === 'string' && login !== '' ? login : null
39
+ }
40
+
41
+ // The rows of the assigned-ticket read (the `gh issue list --json` shape) as tickets tagged with their scope.
42
+ // The ticket parser does the quarantine and the field hygiene; a row with no usable scope is dropped.
43
+ export const myTicketsFromRows = (rows: readonly unknown[]): readonly MyTicket[] => {
44
+ const scopes = new Map(
45
+ rows.flatMap(r => (isRecord(r) && typeof r.number === 'number' ? [[r.number, scopeOf(namesOf(r))] as const] : [])),
46
+ )
47
+ const parsed = [...ticketsFromRows(rows).tickets, ...ticketsFromRows(rows, { isWhiteboard: true }).tickets]
48
+ return parsed
49
+ .flatMap(t => {
50
+ const scope = scopes.get(t.number) ?? null
51
+ return scope === null ? [] : [{ ...t, scope }]
52
+ })
53
+ .sort((a, b) => a.number - b.number)
54
+ }
55
+
56
+ const isSameScope = (a: TicketScope, b: TicketScope): boolean =>
57
+ a.kind === 'whiteboard' ? b.kind === 'whiteboard' : b.kind === 'map' && a.number === b.number
58
+
59
+ // My in-progress tickets (the same grouping the view uses) outside the shown scope, never one it already lists.
60
+ export const elsewhereInProgress = (
61
+ mine: readonly MyTicket[],
62
+ here: TicketScope,
63
+ shown: readonly PaneTicket[],
64
+ ): readonly MyTicket[] => {
65
+ const listed = new Set(shown.map(t => t.number))
66
+ const inProgress = new Set(groupTickets(mine).inProgress.map(t => t.number))
67
+ return mine.filter(t => inProgress.has(t.number) && !isSameScope(t.scope, here) && !listed.has(t.number))
68
+ }
69
+
70
+ // The dim line under such a row: `on map #<n> <title>` (the title once the maps read knows it) or `on whiteboard`.
71
+ export const scopeLine = (t: MyTicket, maps: readonly MapSummary[], columns: number): string => {
72
+ if (t.scope.kind === 'whiteboard') return fit('on whiteboard', columns)
73
+ const { number } = t.scope
74
+ const title = maps.find(m => m.number === number)?.title ?? ''
75
+ return fit(title === '' ? `on map #${number}` : `on map #${number} ${title}`, columns)
76
+ }