@marcomigozzi/trailhead 0.16.0 → 0.17.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 (88) hide show
  1. package/README.it.md +67 -18
  2. package/README.md +67 -18
  3. package/bin/capture-action.test.js +167 -0
  4. package/bin/capture-docs.test.js +32 -0
  5. package/bin/config-dir-templates-docs.test.js +52 -0
  6. package/bin/decision-builds-docs.test.js +75 -0
  7. package/bin/engine-route-docs.test.js +148 -0
  8. package/bin/fog-vs-ticket-docs.test.js +56 -0
  9. package/bin/grill-model-docs.test.js +155 -0
  10. package/bin/grilling-holistic-review-docs.test.js +7 -3
  11. package/bin/lib/codex-projection.js +30 -14
  12. package/bin/lib/codex-projection.test.js +81 -17
  13. package/bin/lib/host-descriptor.js +15 -2
  14. package/bin/lib/host-descriptor.test.js +24 -0
  15. package/bin/lib/runtime-command-literals.test.js +184 -0
  16. package/bin/report-status-docs.test.js +11 -3
  17. package/bin/session-trust-docs.test.js +37 -0
  18. package/bin/surfaced-scope-docs.test.js +57 -0
  19. package/bin/trailhead.js +7 -5
  20. package/bin/trailhead.test.js +53 -11
  21. package/bin/unblock-docs.test.js +56 -0
  22. package/package.json +1 -1
  23. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  24. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  25. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  26. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  27. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  28. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  29. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  30. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  31. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  32. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  33. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  34. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  35. package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
  36. package/plugins/trailhead/hooks/lib/model-defaults-review.js +72 -22
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +113 -0
  38. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  39. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  40. package/plugins/trailhead/hooks/lib/report-status.test.js +124 -2
  41. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  42. package/plugins/trailhead/hooks/lib/statusline-context.test.js +22 -1
  43. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  44. package/plugins/trailhead/hooks/mod/README.md +24 -10
  45. package/plugins/trailhead/hooks/mod/map-pane.tsx +124 -44
  46. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  47. package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
  48. package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
  49. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  50. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +40 -1
  51. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +68 -0
  52. package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
  53. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  54. package/plugins/trailhead/skills/_shared/configuration-reference.md +20 -18
  55. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  56. package/plugins/trailhead/skills/_shared/session-handoff.md +2 -2
  57. package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
  58. package/plugins/trailhead/skills/_shared/substrate.md +1 -1
  59. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  60. package/plugins/trailhead/skills/_shared/teamwork.md +9 -5
  61. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  62. package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
  63. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
  64. package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
  65. package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
  66. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  67. package/plugins/trailhead/skills/_shared/techniques/verify.md +4 -2
  68. package/plugins/trailhead/skills/_shared/techniques.md +7 -5
  69. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  70. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  71. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  72. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  73. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
  74. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +5 -4
  75. package/plugins/trailhead/skills/trailhead-work/SKILL.md +13 -11
  76. package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
  77. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +11 -10
  78. package/plugins/trailhead/templates/statusline-trailhead.sh +3 -3
  79. package/plugins/trailhead/templates/trailhead-capture.js +418 -0
  80. package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
  81. package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
  82. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  83. package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
  84. package/plugins/trailhead/templates/trailhead-report-status.js +123 -11
  85. package/plugins/trailhead/tests/map-pane-ui.test.ts +299 -37
  86. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  87. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  88. 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,6 +48,7 @@ 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'
52
53
  import { isMainTurnRunning } from './turn-state'
53
54
  import { createdIssueNumber, isWhiteboardCreate, nextFollow, whiteboardHint } from './whiteboard-follow'
@@ -79,6 +80,9 @@ const TICKETS_QUERY = `query($owner:String!,$repo:String!,$label:String!,$after:
79
80
  // most UAT_MAX_PAGES pages). The scope is filtered by the caller: GraphQL's `labels` is any-of.
80
81
  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
82
  const UAT_MAX_PAGES = 10
83
+ // The gh account's login, then its open tickets on any map or the whiteboard (paged like TICKETS_QUERY).
84
+ const LOGIN_QUERY = 'query{viewer{login}}'
85
+ 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
86
  const DASHBOARD_FIELDS = 'number,url'
83
87
  // Same colours as the band.
84
88
  const ACCENT = '#7aa2f7'
@@ -123,6 +127,8 @@ export const viewedMap = atom({ plugin: 'trailhead', key: 'viewedMap' } as const
123
127
  export const isDashboard = atom({ plugin: 'trailhead', key: 'isDashboard' } as const, false)
124
128
  export const dashboardPane = atom({ plugin: 'trailhead', key: 'dashboardPane' } as const, EMPTY_DASHBOARD)
125
129
  // The ticket whose `work` or `resume` press is queued; one at a time.
130
+ // My open tickets across every map and the whiteboard (the last good read), for every view's In progress group.
131
+ export const myTickets = atom({ plugin: 'trailhead', key: 'myTickets' } as const, [] as readonly MyTicket[])
126
132
  export const pendingWork = atom({ plugin: 'trailhead', key: 'pendingWork' } as const, null as number | null)
127
133
  // Whether that press waits for the running turn to end (cancellable) or was sent to the host.
128
134
  export const pendingPhase = atom({ plugin: 'trailhead', key: 'pendingPhase' } as const, null as 'queued' | 'starting' | null)
@@ -159,8 +165,12 @@ function debug($: Dollar, message: string): void {
159
165
  // A hot reload resets it, which costs at most one early refresh.
160
166
  let queue: Promise<void> = Promise.resolve()
161
167
  let generation = 0
168
+ // Bumped by every pin move, same repo or not: a read that spans one is stale even when `generation` held.
169
+ let pinMoves = 0
162
170
  let lastStartedAt: number | null = null
163
- let trailing: Timer | null = null
171
+ // When the last tracker write finished: a turn end that finds it newer than the last read start reads at once.
172
+ let lastWriteAt: number | null = null
173
+ let trailing: Tracked | null = null
164
174
  let ticker: Tracked | null = null
165
175
  // What /clear would take from the pane, kept across it (module variables survive it, atoms do not).
166
176
  type Carried = { isAutoOpened: boolean; viewed: number | null; active: number | null; isDashboard: boolean }
@@ -245,6 +255,40 @@ const readOpen = async ($: Dollar, pin: RepoPin, label: string, also?: string):
245
255
  const readOpenFallback = ($: Dollar, pin: RepoPin, label: string, also?: string) =>
246
256
  gh($, pin, ['issue', 'list', ...(also === undefined ? [] : ['--label', also]), '--label', label, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS])
247
257
 
258
+ // The gh login per host, read once: the account does not change under a running session.
259
+ const loginByHost = new Map<string, string>()
260
+ const readLogin = async ($: Dollar, pin: RepoPin): Promise<string | null> => {
261
+ const known = loginByHost.get(pin.host)
262
+ if (known !== undefined) return known
263
+ const out = await gh($, pin, ['api', 'graphql', '-f', `query=${LOGIN_QUERY}`])
264
+ const login = out.ok ? parseLogin(out.stdout) : null
265
+ if (login !== null) loginByHost.set(pin.host, login)
266
+ return login
267
+ }
268
+
269
+ // My open tickets in the repo, on any open map or the whiteboard. Fail-closed like the UAT read: a login or a page
270
+ // that fails or is unreadable makes the read null, and the last good list stays; never a partial list.
271
+ const readMine = async ($: Dollar, pin: RepoPin): Promise<readonly MyTicket[] | null> => {
272
+ const login = await readLogin($, pin)
273
+ if (login === null) return null
274
+ const rows: unknown[] = []
275
+ let cursor: string | null = null
276
+ while (rows.length < LIST_LIMIT) {
277
+ const args = ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MINE_QUERY}`, '-f', `login=${login}`]
278
+ const out = await gh($, pin, cursor === null ? args : [...args, '-f', `after=${cursor}`])
279
+ const page = out.ok ? parseIssuesPage(out.stdout) : null
280
+ if (page === null) return null
281
+ rows.push(...page.rows)
282
+ if (!page.hasNextPage) break
283
+ cursor = page.endCursor
284
+ }
285
+ return myTicketsFromRows(rows)
286
+ }
287
+
288
+ const keepMine = async ($: Dollar, mine: readonly MyTicket[] | null) => {
289
+ if (mine !== null && !sameValue(await read($, myTickets), mine)) await update($, myTickets, () => mine)
290
+ }
291
+
248
292
  // The pending UAT tickets of one scope (a map label, or the whiteboard) with their bodies, for the parent each
249
293
  // one tests. Read through GraphQL (the search index lags a write by seconds, so a follow-up just opened would
250
294
  // show without its parent line), all open `trailhead:uat` tickets, the scope filtered here. Fail-closed: a page
@@ -307,18 +351,18 @@ async function dropClosedView($: Dollar, maps: readonly MapSummary[] | null) {
307
351
  }
308
352
 
309
353
  // One read of the viewed (else active) map. The result is dropped when the pane closed meanwhile
310
- // (generation) or the target moved on, checked before every write; a moved target is read again.
311
- async function fetchMap($: Dollar, pin: RepoPin, token: number, wantsAnother: () => void) {
354
+ // (generation) or the target or pin moved on, checked before every write; a moved one is read again.
355
+ async function fetchMap($: Dollar, pin: RepoPin, token: number, moves: number, wantsAnother: () => void) {
312
356
  const target = await readTarget($)
313
357
  if (token !== generation) return
314
- // Checked before every write: the last test is the token, with no await after it. A pin that moved to
315
- // another repo (even with the same map number there) makes the read stale; the target is not read
316
- // under the new pin for it.
358
+ // Checked before every write: the last test is the token, then the pin-move count, with no await after
359
+ // them. A pin that moved to another repo (even with the same map number there) makes the read stale; the
360
+ // target is not read under the new pin for it. A move that landed during the checks shows in the count.
317
361
  const isStale = async () => {
318
362
  if (token !== generation) return true
319
363
  const isMoved = !isSamePin(await read($, repoPin), pin) || (await read($, isDashboard)) || (await readTarget($)) !== target
320
364
  if (token !== generation) return true
321
- if (!isMoved) return false
365
+ if (!isMoved && moves === pinMoves) return false
322
366
  wantsAnother()
323
367
  return true
324
368
  }
@@ -337,12 +381,13 @@ async function fetchMap($: Dollar, pin: RepoPin, token: number, wantsAnother: ()
337
381
  await setPane($, { ...EMPTY, maps: before.maps, whiteboard: before.whiteboard, openTotal: before.openTotal, isLoading: true })
338
382
  }
339
383
 
340
- const [view, list, historyRead, mapsRead, uatParents] = await readList($, pin, target, before.openTotal)
384
+ const [[view, list, historyRead, mapsRead, uatParents], mine] = await Promise.all([readList($, pin, target, before.openTotal), readMine($, pin)])
341
385
  const maps = mapsRead.maps ?? before.maps
342
386
  const { whiteboard, openTotal } = mapsRead
343
387
  await dropClosedView($, mapsRead.maps)
344
388
  const now = await $.clock.now()
345
389
  if (await isStale()) return
390
+ await keepMine($, mine)
346
391
 
347
392
  const kept = isSameMap ? before : EMPTY
348
393
  const failure = !view.ok ? view.reason : !list.ok ? list.reason : null
@@ -362,13 +407,13 @@ async function fetchMap($: Dollar, pin: RepoPin, token: number, wantsAnother: ()
362
407
 
363
408
  // The whiteboard's open tickets (map-less) and the pinned index, plus the open maps. Same drop rule
364
409
  // as a map read: a result is not committed once the pane closed or the dashboard was left.
365
- async function fetchDashboard($: Dollar, pin: RepoPin, token: number, wantsAnother: () => void) {
410
+ async function fetchDashboard($: Dollar, pin: RepoPin, token: number, moves: number, wantsAnother: () => void) {
366
411
  const isStale = async () => {
367
412
  if (token !== generation) return true
368
413
  const isOther = !isSamePin(await read($, repoPin), pin)
369
414
  const isLeft = !(await read($, isDashboard))
370
415
  if (token !== generation) return true
371
- if (!isOther && !isLeft) return false
416
+ if (!isOther && !isLeft && moves === pinMoves) return false
372
417
  wantsAnother()
373
418
  return true
374
419
  }
@@ -377,14 +422,16 @@ async function fetchDashboard($: Dollar, pin: RepoPin, token: number, wantsAnoth
377
422
  if (before.fetchedAt === null && !before.isLoading && before.error === null) {
378
423
  await update($, dashboardPane, () => ({ ...before, isLoading: true }))
379
424
  }
380
- const [list, index, mapsRead, uatParents] = await Promise.all([
425
+ const [list, index, mapsRead, uatParents, mine] = await Promise.all([
381
426
  readOpen($, pin, 'trailhead:whiteboard', 'trailhead:ticket'),
382
427
  gh($, pin, ['issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', DASHBOARD_FIELDS]),
383
428
  readMaps($, pin, keptTotal),
384
429
  readUat($, pin, 'trailhead:whiteboard'),
430
+ readMine($, pin),
385
431
  ])
386
432
  const now = await $.clock.now()
387
433
  if (await isStale()) return
434
+ await keepMine($, mine)
388
435
  const shown = await read($, mapPane)
389
436
  await setPane($, { ...shown, maps: mapsRead.maps ?? shown.maps, whiteboard: mapsRead.whiteboard, openTotal: mapsRead.openTotal })
390
437
  await dropClosedView($, mapsRead.maps)
@@ -404,16 +451,19 @@ async function fetchDashboard($: Dollar, pin: RepoPin, token: number, wantsAnoth
404
451
  // holds for the old one (and drops a read of it still in flight), so its tickets never show under the new.
405
452
  let paneRepo: string | null = null
406
453
  // Called by refreshPin right before the pin atom changes, from the pane's refresh and the band's alike. A move
407
- // to another repo drops the reads in flight and empties what the pane holds for the old one, then asks for a
408
- // fresh read (a closed pane makes that a no-op), so its tickets never show under the new repo.
454
+ // to another repo (or to no pin) drops the reads in flight and, to another repo, empties what the pane holds for
455
+ // the old one, then asks for a fresh read (a closed pane makes that a no-op), so its tickets never show under the
456
+ // new repo. A move inside the same repo leaves the read in flight to its own stale check, which reads again.
409
457
  export async function onPinMove($: Dollar, pin: RepoPin | null): Promise<void> {
410
- generation += 1
458
+ pinMoves += 1
411
459
  const key = pin === null ? null : pinKey(pin)
412
460
  const isOtherRepo = paneRepo !== null && key !== null && key !== paneRepo
461
+ if (isOtherRepo || pin === null) generation += 1
413
462
  paneRepo = key ?? paneRepo
414
463
  if (!isOtherRepo) return
415
464
  await update($, mapPane, () => EMPTY)
416
465
  await update($, dashboardPane, () => EMPTY_DASHBOARD)
466
+ await update($, myTickets, () => [])
417
467
  await update($, activeMap, () => null)
418
468
  await update($, viewedMap, () => null)
419
469
  await update($, isDashboard, () => false)
@@ -435,25 +485,28 @@ function stopTimers() {
435
485
  generation += 1
436
486
  // A fill timer dropped with the pane must not leave the next pane unfillable.
437
487
  isFilling = false
438
- trailing?.cancel()
488
+ trailing?.timer.cancel()
439
489
  trailing = null
490
+ lastWriteAt = null
440
491
  ticker?.timer.cancel()
441
492
  ticker = null
442
493
  }
443
494
 
444
- // The slow timer past its due time by more than the grace never fired: drop it, so the caller starts another.
445
- // A dropped trailing needs no such check: the next read outside the floor clears it.
495
+ // A timer past its due time by more than the grace never fired: drop it, so the caller starts another.
496
+ // Both the slow timer and the trailing refresh are tracked, as the host may silently drop either.
446
497
  const liveOrNull = (t: Tracked | null, now: number): Tracked | null => {
447
498
  if (t === null || !isTimerLate(now, t.dueAt, TIMER_GRACE_MS)) return t
448
499
  t.timer.cancel()
449
500
  return null
450
501
  }
451
502
 
452
- const startTrailing = ($: Dollar, waitMs: number): Timer =>
453
- $.clock.after(waitMs, () => {
503
+ const startTrailing = ($: Dollar, now: number, waitMs: number): Tracked => ({
504
+ timer: $.clock.after(waitMs, () => {
454
505
  trailing = null
455
506
  void requestRefresh($)
456
- })
507
+ }),
508
+ dueAt: now + waitMs,
509
+ })
457
510
 
458
511
  const startTicker = ($: Dollar, now: number): Tracked => ({
459
512
  timer: $.clock.every(TIMER_MS, () => void requestRefresh($, { isTick: true })),
@@ -464,7 +517,9 @@ const startTicker = ($: Dollar, now: number): Tracked => ({
464
517
  // `isWrite`: a tracker write just ran; a read that starts at once is followed by one more at the floor's end,
465
518
  // by when the search index (history, the list fallback) has caught up.
466
519
  // `isTick`: the slow timer fired, so its next due time moves on.
467
- type Request = { bypassFloor?: boolean; isWrite?: boolean; isTick?: boolean }
520
+ // `isTurnEnd`: the turn is over; if a write landed after the last read started, the read runs inside the floor
521
+ // too, so the pane shows the turn's final tracker state without waiting on a timer the host may have dropped.
522
+ type Request = { bypassFloor?: boolean; isWrite?: boolean; isTick?: boolean; isTurnEnd?: boolean }
468
523
 
469
524
  // Throttled: inside the floor it keeps ONE trailing refresh for the rest of it, so an event
470
525
  // just after a refresh still shows.
@@ -472,24 +527,32 @@ async function decide($: Dollar, request: Request) {
472
527
  if (!(await isPaneUp($))) return
473
528
  // Nothing pinned: no repo to read, so no call.
474
529
  const pin = await syncPin($)
530
+ const moves = pinMoves
475
531
  if (pin === null) return
476
532
  const now = await $.clock.now()
477
533
  if (request.isTick === true && ticker !== null) ticker = { ...ticker, dueAt: now + TIMER_MS }
478
534
  // A timer the host dropped is started again here, else the pane would wait on it for good.
479
535
  ticker = liveOrNull(ticker, now) ?? startTicker($, now)
480
- const decision = request.bypassFloor === true ? { run: true as const } : refreshDecision(now, lastStartedAt, MIN_REFRESH_MS)
536
+ // Same for the trailing refresh: a dead handle would keep `??=` below from arming a live one.
537
+ trailing = liveOrNull(trailing, now)
538
+ // Strictly newer: a read that started at the write's own instant already saw it.
539
+ const isFlush = request.isTurnEnd === true && lastWriteAt !== null && (lastStartedAt === null || lastWriteAt > lastStartedAt)
540
+ const decision = request.bypassFloor === true || isFlush ? { run: true as const } : refreshDecision(now, lastStartedAt, MIN_REFRESH_MS)
481
541
  if (!decision.run) {
482
- trailing ??= startTrailing($, decision.waitMs)
542
+ trailing ??= startTrailing($, now, decision.waitMs)
483
543
  return
484
544
  }
485
545
  lastStartedAt = now
486
- trailing?.cancel()
487
- trailing = null
546
+ // A flush leaves the trailing refresh alone: it still owes the read after the search index caught up.
547
+ if (!isFlush) {
548
+ trailing?.timer.cancel()
549
+ trailing = null
550
+ }
488
551
  // Never bypassFloor: a burst of writes still costs one read per floor plus this one trailing.
489
- if (request.isWrite === true) trailing = startTrailing($, MIN_REFRESH_MS)
552
+ if (request.isWrite === true) trailing = startTrailing($, now, MIN_REFRESH_MS)
490
553
  const again = () => void requestRefresh($, { bypassFloor: true })
491
- if (await read($, isDashboard)) await fetchDashboard($, pin, generation, again)
492
- else await fetchMap($, pin, generation, again)
554
+ if (await read($, isDashboard)) await fetchDashboard($, pin, generation, moves, again)
555
+ else await fetchMap($, pin, generation, moves, again)
493
556
  }
494
557
 
495
558
  // Does nothing while the pane is closed: the data exists only for the pane.
@@ -832,19 +895,26 @@ async function autoOpen($: Dollar) {
832
895
 
833
896
  // Un pannello aperto ma non disegnato (l'auto-open sotto la soglia dei pannelli non richiesti, tipico di
834
897
  // una sessione appena aperta) non è visibile: il comando lo riapre, e una richiesta della persona lo
835
- // disegna a qualsiasi larghezza. Se nemmeno così viene disegnato (nessuna superficie), lo chiude.
898
+ // disegna a qualsiasi larghezza. Se nemmeno così viene disegnato lo chiude, tranne quando non c'è ancora
899
+ // nessuna superficie (`no-surface`): è l'attesa normale dell'inizio sessione, il pannello resta aperto e
900
+ // viene disegnato appena una superficie si collega (il suo render chiede la prima lettura).
901
+ const PENDING_MESSAGE = 'Map panel will open as soon as the screen is ready.'
902
+ const isNoSurface = (reason: string) => reason.startsWith('no-surface')
903
+
836
904
  async function togglePanel($: Dollar): Promise<string> {
837
905
  const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
838
906
  if (pane?.isPlaced === false) {
839
907
  const reopened = await openPane($)
840
908
  if (reopened.isPlaced) return 'Map panel opened.'
909
+ if (isNoSurface(reopened.reason)) return PENDING_MESSAGE
841
910
  }
842
911
  if (pane !== undefined) {
843
912
  await $.ui.close({ id: PANE_ID })
844
913
  return 'Map panel closed.'
845
914
  }
846
915
  const opened = await openPane($)
847
- return opened.isPlaced ? 'Map panel opened.' : `Map panel not shown: ${opened.reason}`
916
+ if (opened.isPlaced) return 'Map panel opened.'
917
+ return isNoSurface(opened.reason) ? PENDING_MESSAGE : `Map panel not shown: ${opened.reason}`
848
918
  }
849
919
 
850
920
  // The close button: the pane goes (its `ui.close` hook stops the timers). The press counts as the
@@ -876,7 +946,7 @@ export function registerMapPane(on: On) {
876
946
  const result = await next(e)
877
947
  await safeSyncPin($)
878
948
  await safeReadActiveMap($)
879
- void requestRefresh($)
949
+ void requestRefresh($, { isTurnEnd: true })
880
950
  // The band's hook marks the turn over; the timer runs after the whole chain, and releases a held press.
881
951
  if (e.agentId === undefined) $.clock.after(0, () => releaseHeld($))
882
952
  return result
@@ -903,6 +973,7 @@ export function registerMapPane(on: On) {
903
973
  // After the call ran: a Bash `gh issue` write changed the tracker.
904
974
  on('tool.call', { tool: 'Bash', command: GH_ISSUE_WRITE }, async ($, e, next) => {
905
975
  const result = await next(e)
976
+ lastWriteAt = await $.clock.now()
906
977
  void requestRefresh($, { isWrite: true })
907
978
  await hintWhiteboard($, e.command, result)
908
979
  return result
@@ -941,6 +1012,7 @@ export function registerMapPane(on: On) {
941
1012
  const play = await read($, workState)
942
1013
  const orphaned = await read($, isOrphaned)
943
1014
  const own = await read($, ownTicket)
1015
+ const mine = await read($, myTickets)
944
1016
  const pin = await read($, repoPin)
945
1017
  const pending = await read($, pendingWork)
946
1018
  const phase = await read($, pendingPhase)
@@ -1019,7 +1091,7 @@ export function registerMapPane(on: On) {
1019
1091
 
1020
1092
  // This session's own ticket survives a work-state file another session took over; without one, the file decides.
1021
1093
  const isInPlay = (t: PaneTicket) => (own !== null ? t.number === own : play !== null && !orphaned && play.ticket === t.number)
1022
- const row = (t: PaneTicket, hasMarkColumn: boolean) => {
1094
+ const row = (t: PaneTicket, hasMarkColumn: boolean, scope: string | null = null) => {
1023
1095
  const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, pendingPhase: phase, columns: cols })
1024
1096
  const style = r.isInPlay ? accent : r.isDim ? dim : {}
1025
1097
  const holder = r.isDim ? dim : {}
@@ -1059,18 +1131,20 @@ export function registerMapPane(on: On) {
1059
1131
  </Box>
1060
1132
  )
1061
1133
  const parent = uatParentLine(t, cols)
1062
- return parent === null ? (
1134
+ return parent === null && scope === null ? (
1063
1135
  line
1064
1136
  ) : (
1065
1137
  <Box key={`entry-${t.number}`} flexDirection="column">
1066
1138
  {line}
1067
- {text(`uat-of-${t.number}`, dim, parent)}
1139
+ {parent === null ? null : text(`uat-of-${t.number}`, dim, parent)}
1140
+ {scope === null ? null : text(`scope-of-${t.number}`, dim, scope)}
1068
1141
  </Box>
1069
1142
  )
1070
1143
  }
1071
- const group = (key: string, name: string, rows: readonly PaneTicket[]) => {
1072
- if (rows.length === 0) return null
1073
- const hasMarkColumn = rows.some(isInPlay)
1144
+ // `elsewhere`: my tickets of other scopes, listed after the shown scope's with the line naming where each is.
1145
+ const group = (key: string, name: string, rows: readonly PaneTicket[], elsewhere: readonly MyTicket[] = []) => {
1146
+ if (rows.length + elsewhere.length === 0) return null
1147
+ const hasMarkColumn = [...rows, ...elsewhere].some(isInPlay)
1074
1148
  return (
1075
1149
  <Box
1076
1150
  key={`group-${key}`}
@@ -1078,17 +1152,19 @@ export function registerMapPane(on: On) {
1078
1152
  marginTop={gap}
1079
1153
  {...(e.surface === 'desktop' ? { rowGap: DESKTOP_ROW_GAP } : {})}
1080
1154
  >
1081
- {text(`head-${key}`, bold, `${name} · ${rows.length}`)}
1155
+ {text(`head-${key}`, bold, `${name} · ${rows.length + elsewhere.length}`)}
1082
1156
  {rows.map(t => row(t, hasMarkColumn))}
1157
+ {elsewhere.map(t => row(t, hasMarkColumn, scopeLine(t, data.maps, cols)))}
1083
1158
  </Box>
1084
1159
  )
1085
1160
  }
1086
- const ticketGroups = (tickets: readonly PaneTicket[]) => {
1161
+ const ticketGroups = (tickets: readonly PaneTicket[], here: TicketScope) => {
1087
1162
  const groups = groupTickets(tickets)
1163
+ // In progress first, right under the maps list: what I already hold comes before what to pick up.
1088
1164
  return [
1165
+ group('in-progress', 'In progress', groups.inProgress, elsewhereInProgress(mine, here, tickets)),
1089
1166
  group('needs-you', 'Needs you', groups.needsYou),
1090
1167
  group('frontier', 'Frontier', groups.frontier),
1091
- group('in-progress', 'In progress', groups.inProgress),
1092
1168
  group('uat', 'UAT', groups.uat),
1093
1169
  group('paused', 'Paused', groups.paused),
1094
1170
  group('blocked', 'Blocked', groups.blocked),
@@ -1146,8 +1222,12 @@ export function registerMapPane(on: On) {
1146
1222
 
1147
1223
  // A list older than one slow-timer period missed its reads (the pane undrawn when they came, or the
1148
1224
  // timer dropped): the render asks for one, as for an empty view. A failing read keeps its slow pace.
1225
+ // A trailing refresh past its due time by more than the grace was dropped by the host: the render asks for
1226
+ // the read it would have made (the request replaces the dead handle).
1227
+ const isTrailingLate = trailing !== null && isTimerLate(now, trailing.dueAt, TIMER_GRACE_MS)
1149
1228
  const isOverdue = (d: { fetchedAt: number | null; error: string | null; isLoading: boolean }) =>
1150
- d.fetchedAt !== null && d.error === null && !d.isLoading && isTimerLate(now, d.fetchedAt + TIMER_MS, TIMER_GRACE_MS)
1229
+ isTrailingLate ||
1230
+ (d.fetchedAt !== null && d.error === null && !d.isLoading && isTimerLate(now, d.fetchedAt + TIMER_MS, TIMER_GRACE_MS))
1151
1231
 
1152
1232
  // No GitHub repo was ever pinned: say so rather than a `gh` failure; nothing is read.
1153
1233
  if (pin === null) {
@@ -1177,7 +1257,7 @@ export function registerMapPane(on: On) {
1177
1257
  {text('head-whiteboard', bold, whiteboardHeading(isLoaded ? board.tickets : null, board.isCapped))}
1178
1258
  </Box>
1179
1259
  {!isLoaded && board.error === null ? text('loading', dim, 'Loading dashboard…') : null}
1180
- {ticketGroups(board.tickets)}
1260
+ {ticketGroups(board.tickets, { kind: 'whiteboard' })}
1181
1261
  {isEmpty ? text('no-tickets', dim, 'No open whiteboard tickets.') : null}
1182
1262
  {notes(board)}
1183
1263
  <Box key="spacer" flexGrow={1} />
@@ -1250,7 +1330,7 @@ export function registerMapPane(on: On) {
1250
1330
  {whiteboardRow}
1251
1331
  {totalLine}
1252
1332
  {mapsGroup}
1253
- {ticketGroups(tickets)}
1333
+ {ticketGroups(tickets, { kind: 'map', number: target })}
1254
1334
  {isCurrent && data.error === null && tickets.length === 0 ? (
1255
1335
  <Box key="no-tickets" marginTop={gap}>
1256
1336
  <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
+ }
@@ -46,13 +46,16 @@ export const nextPin = (current: RepoPin | null, repo: RepoRead | null, toplevel
46
46
  }
47
47
 
48
48
  // The pinned checkout as the trailing word of a verb's args: `--in=` plus base64url of the root (alphabet
49
- // [A-Za-z0-9_-], no padding), so a path with spaces, quotes or a leading `-` stays one shell-safe word. The verb
50
- // decodes it with templates/trailhead-checkout-in.js and moves there first (#312).
49
+ // [A-Za-z0-9_-], no padding), so a path with spaces, quotes or a leading `-` stays one shell-safe word, then `.` and
50
+ // base64url of `host/owner/name` (`.` is outside the alphabet, so it separates unambiguously). The verb decodes it
51
+ // with templates/trailhead-checkout-in.js and moves there first; when the root is gone, the repo part lets the
52
+ // helper find the checkout by its remote (#312, #329).
51
53
  // No Buffer in the mod's runtime: UTF-8 bytes through btoa, then the url-safe alphabet.
52
- export const checkoutArg = (pin: RepoPin): string => {
53
- const binary = Array.from(new TextEncoder().encode(pin.root), byte => String.fromCharCode(byte)).join('')
54
- return `--in=${btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`
54
+ const base64url = (text: string): string => {
55
+ const binary = Array.from(new TextEncoder().encode(text), byte => String.fromCharCode(byte)).join('')
56
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
55
57
  }
58
+ export const checkoutArg = (pin: RepoPin): string => `--in=${base64url(pin.root)}.${base64url(pinKey(pin))}`
56
59
 
57
60
  // A repo-relative path under the pinned root, absolute whatever the session cwd is.
58
61
  export const pinPath = (pin: RepoPin, relative: string): string => `${pin.root.replace(/\/+$/, '')}/${relative}`
@@ -186,6 +186,21 @@ function labelGuardNotice(cwd) {
186
186
  }
187
187
  }
188
188
 
189
+ // Preflight dell'auth di gh (#328): un GH_TOKEN/GITHUB_TOKEN non valido che
190
+ // oscura il login gh funzionante fa fallire ogni chiamata gh; lo segnaliamo
191
+ // una volta, all'avvio (la lib salta resume e compact). Come reviewNotice: il
192
+ // require e' nel try, ogni errore -> null.
193
+ function authNotice(input) {
194
+ try {
195
+ return require('./lib/gh-auth-preflight.js').check({
196
+ env: process.env,
197
+ source: input && typeof input.source === 'string' ? input.source : undefined,
198
+ }) || null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
189
204
  // Legge il JSON di SessionStart da stdin in modo asincrono, con timeout, come
190
205
  // gli altri hook: una lettura sincrona di fd 0 si bloccherebbe per sempre su
191
206
  // un TTY o su una pipe mai chiusa. Su TTY o input assente si prosegue con {}.
@@ -212,11 +227,12 @@ function readInput(done) {
212
227
  function main(input) {
213
228
  const cwd = (input && typeof input.cwd === 'string' && input.cwd) || process.cwd();
214
229
 
230
+ const auth = authNotice(input);
215
231
  const review = reviewNotice(cwd);
216
232
  const guard = labelGuardNotice(cwd);
217
233
  const update = updateNotice();
218
234
 
219
- const parts = [review, guard, update].filter(Boolean);
235
+ const parts = [auth, review, guard, update].filter(Boolean);
220
236
  if (!parts.length) return;
221
237
 
222
238
  process.stdout.write(JSON.stringify({
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ // Test dell'hook SessionStart trailhead-check-update.js (notice auth di gh).
3
+ // Run: node trailhead-check-update.test.js
4
+ // Un gh finto in una bin dir tmp (prima nel PATH) applica la precedenza reale.
5
+ const assert = require('assert');
6
+ const { execFileSync } = require('child_process');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+
11
+ let passed = 0;
12
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
13
+
14
+ const hookPath = path.join(__dirname, 'trailhead-check-update.js');
15
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-check-update-test-'));
16
+ const bin = path.join(tmp, 'bin');
17
+ fs.mkdirSync(bin);
18
+ const fakeGh = `#!${process.execPath}
19
+ const a = process.argv.slice(2);
20
+ if (a[0] !== 'api' || a[1] !== 'user') process.exit(1); // altre chiamate (update check): innocue
21
+ const t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
22
+ const v = t ? t : (process.env.FAKE_GH_STORED || 'bad');
23
+ if (v === 'good') { console.log('octo'); process.exit(0); }
24
+ console.log('{"message":"Bad credentials","status":"401"}');
25
+ console.error('gh: Bad credentials (HTTP 401)');
26
+ process.exit(1);
27
+ `;
28
+ fs.writeFileSync(path.join(bin, 'gh'), fakeGh, { mode: 0o755 });
29
+
30
+ const repo = path.join(tmp, 'repo');
31
+ execFileSync('git', ['init', '-q', repo]);
32
+
33
+ function run(source, extraEnv) {
34
+ const env = { ...process.env, ...extraEnv };
35
+ delete env.GH_TOKEN; delete env.GITHUB_TOKEN;
36
+ Object.assign(env, extraEnv, {
37
+ PATH: `${bin}${path.delimiter}${process.env.PATH}`,
38
+ XDG_CACHE_HOME: path.join(tmp, 'cache'),
39
+ CLAUDE_CONFIG_DIR: path.join(tmp, 'cfg'),
40
+ });
41
+ const input = { cwd: repo };
42
+ if (source) input.source = source;
43
+ const out = execFileSync('node', [hookPath], { input: JSON.stringify(input), encoding: 'utf8', env });
44
+ return out ? JSON.parse(out).hookSpecificOutput.additionalContext : '';
45
+ }
46
+ const AUTH = 'GitHub rejects the';
47
+
48
+ for (const source of ['startup', 'clear', undefined]) {
49
+ const ctx = run(source, { GITHUB_TOKEN: 'bad', FAKE_GH_STORED: 'good' });
50
+ ok(`source ${source}: auth notice surfaced`, ctx.includes(`${AUTH} GITHUB_TOKEN`) && ctx.includes('GITHUB_TOKEN= gh'));
51
+ ok(`source ${source}: token value not leaked`, !ctx.includes('bad'));
52
+ }
53
+ for (const source of ['resume', 'compact']) {
54
+ ok(`source ${source}: no auth notice`, !run(source, { GITHUB_TOKEN: 'bad', FAKE_GH_STORED: 'good' }).includes(AUTH));
55
+ }
56
+ ok('no token var: no auth notice', !run('startup', { FAKE_GH_STORED: 'good' }).includes(AUTH));
57
+ ok('valid token: no auth notice', !run('startup', { GITHUB_TOKEN: 'good' }).includes(AUTH));
58
+
59
+ fs.rmSync(tmp, { recursive: true, force: true });
60
+ console.log(`trailhead-check-update: ${passed} passed`);