@marcomigozzi/trailhead 0.15.0 → 0.16.1

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 (116) hide show
  1. package/README.it.md +52 -14
  2. package/README.md +53 -15
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/config-dir-templates-docs.test.js +52 -0
  12. package/bin/decision-builds-docs.test.js +75 -0
  13. package/bin/fog-vs-ticket-docs.test.js +56 -0
  14. package/bin/grilling-holistic-review-docs.test.js +90 -0
  15. package/bin/lib/codex-projection.js +13 -6
  16. package/bin/lib/codex-projection.test.js +25 -2
  17. package/bin/lib/host-descriptor.js +14 -1
  18. package/bin/lib/host-descriptor.test.js +18 -0
  19. package/bin/lib/runtime-command-literals.test.js +184 -0
  20. package/bin/needs-you-docs.test.js +41 -0
  21. package/bin/pick-stress-test-docs.test.js +105 -0
  22. package/bin/report-status-docs.test.js +1 -1
  23. package/bin/trailhead.js +3 -3
  24. package/bin/trailhead.test.js +17 -18
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  28. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  29. package/plugins/trailhead/commands/auto.md +2 -2
  30. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  31. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  32. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  33. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  34. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  36. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
  38. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  41. package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
  42. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  43. package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
  44. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  45. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  46. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  47. package/plugins/trailhead/hooks/mod/README.md +185 -39
  48. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  49. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  50. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  51. package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
  52. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  53. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  54. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  55. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  56. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  57. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  58. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  59. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  60. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  61. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  62. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  63. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  64. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  65. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
  66. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
  67. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  68. package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
  69. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  70. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  71. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  72. package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
  73. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  74. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  75. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  76. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  77. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  78. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  79. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  80. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  81. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  82. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  83. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
  84. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  85. package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
  86. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  87. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
  88. package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
  89. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  90. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  91. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  92. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  93. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  94. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  95. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  96. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  97. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  98. package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
  99. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  100. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  101. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  102. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  103. package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
  104. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  105. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  106. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  107. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  108. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  109. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  110. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  111. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  112. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  113. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  114. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  115. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  116. package/plugins/trailhead/types/index.d.ts +26 -1
@@ -1,13 +1,14 @@
1
1
  // Pure view logic of the map pane: parse what `gh` printed, group the rows, word a
2
2
  // failure and an age, decide when a refresh may run. No engine calls here, so it is easy to test.
3
3
  import { fit } from './work-state-view'
4
- import type { BurndownHistory, BurndownIssue, DashboardRef, MapCounts, MapInfo, MapsRead, MapSummary, PaneTicket, TicketState, TicketType, UatParent } from '../../types'
4
+ import type { BurndownHistory, BurndownIssue, DashboardRef, MapCounts, MapInfo, MapsRead, MapSummary, OpenTotal, PaneTicket, TicketState, TicketType, UatParent } from '../../types'
5
5
 
6
6
  export const TICKET_TYPES: readonly TicketType[] = ['decision', 'research', 'prototype', 'build', 'bug', 'task']
7
7
 
8
8
  // `isCapped`: gh returned as many rows as it was asked for, so the list may be cut.
9
9
  export type ParsedTickets = { tickets: readonly PaneTicket[]; unverifiedCount: number; isCapped: boolean }
10
10
  export type TicketGroups = {
11
+ needsYou: readonly PaneTicket[]
11
12
  frontier: readonly PaneTicket[]
12
13
  inProgress: readonly PaneTicket[]
13
14
  uat: readonly PaneTicket[]
@@ -68,17 +69,10 @@ const stateOf = (names: readonly string[], assignees: readonly string[]): Ticket
68
69
  // Only an https link is kept; anything else would be drawn as plain text.
69
70
  const safeUrl = (raw: unknown): string => (typeof raw === 'string' && /^https:\/\//.test(raw) ? raw : '')
70
71
 
71
- // `gh issue list --json number,title,labels,assignees,url`. Unverified rows are
72
- // quarantined (counted, never listed); whiteboard rows do not belong to a map, and on the
73
- // whiteboard (`isWhiteboard`) only they are kept.
74
- export const parseTickets = (stdout: string, opts: { isWhiteboard?: boolean } = {}): ParsedTickets | null => {
75
- let raw: unknown
76
- try {
77
- raw = JSON.parse(stdout)
78
- } catch {
79
- return null
80
- }
81
- if (!Array.isArray(raw)) return null
72
+ // The rows of an open-ticket list (the `gh issue list --json number,title,labels,assignees,url` shape).
73
+ // Unverified rows are quarantined (counted, never listed); whiteboard rows do not belong to a map, and on the
74
+ // whiteboard (`isWhiteboard`) only they are kept. A list that reached LIST_LIMIT rows is capped.
75
+ export const ticketsFromRows = (raw: readonly unknown[], opts: { isWhiteboard?: boolean } = {}): ParsedTickets => {
82
76
  const rows = raw.filter(isRecord).filter(r => isPosInt(r.number))
83
77
  const named = rows.map(r => ({ r, names: labelNames(r.labels) }))
84
78
  const unverifiedCount = named.filter(({ names }) => names.includes(`${LABEL_PREFIX}unverified`)).length
@@ -95,13 +89,61 @@ export const parseTickets = (stdout: string, opts: { isWhiteboard?: boolean } =
95
89
  assignees,
96
90
  url: safeUrl(r.url),
97
91
  isUat: names.includes(`${LABEL_PREFIX}uat`),
92
+ isNeedsYou: names.includes(`${LABEL_PREFIX}needs-you`),
98
93
  uatOf: null,
99
94
  }
100
95
  })
101
96
  return { tickets, unverifiedCount, isCapped: raw.length >= LIST_LIMIT }
102
97
  }
103
98
 
104
- // `gh issue list --label trailhead:uat ... --json number,body`: ticket number -> the parent its first
99
+ // `gh issue list --json ...` output as a ticket list; not a list is unreadable (null).
100
+ export const parseTickets = (stdout: string, opts: { isWhiteboard?: boolean } = {}): ParsedTickets | null => {
101
+ let raw: unknown
102
+ try {
103
+ raw = JSON.parse(stdout)
104
+ } catch {
105
+ return null
106
+ }
107
+ return Array.isArray(raw) ? ticketsFromRows(raw, opts) : null
108
+ }
109
+
110
+ // The rows that carry `name`: a GraphQL `labels:` filter is any-of, so an AND of two labels is done here.
111
+ export const withLabel = (rows: readonly unknown[], name: string): unknown[] =>
112
+ rows.filter(r => isRecord(r) && labelNames(r.labels).includes(name))
113
+
114
+ export type IssuesPage = { rows: unknown[]; hasNextPage: boolean; endCursor: string | null }
115
+
116
+ // One page of the GraphQL open-ticket read (`repository.issues(...){nodes{...}pageInfo{...}}`), its nodes
117
+ // flattened to the rows `gh issue list --json` prints so one parser serves both (`body` rides along only when
118
+ // the node has a string one, so the ticket rows keep their shape). Any `errors` (even beside
119
+ // partial `data`), a missing page info or a next page without a cursor is unreadable: the caller decides (`readOpen` falls back to
120
+ // `gh issue list`, `readUat` fails closed to null).
121
+ export const parseIssuesPage = (stdout: string): IssuesPage | null => {
122
+ let raw: unknown
123
+ try {
124
+ raw = JSON.parse(stdout)
125
+ } catch {
126
+ return null
127
+ }
128
+ if (!isRecord(raw) || 'errors' in raw || !isRecord(raw.data) || !isRecord(raw.data.repository)) return null
129
+ const issues = raw.data.repository.issues
130
+ if (!isRecord(issues) || !Array.isArray(issues.nodes) || !isRecord(issues.pageInfo)) return null
131
+ const { hasNextPage, endCursor } = issues.pageInfo
132
+ if (typeof hasNextPage !== 'boolean') return null
133
+ if (hasNextPage && (typeof endCursor !== 'string' || endCursor === '')) return null
134
+ const inner = (v: unknown) => (isRecord(v) && Array.isArray(v.nodes) ? v.nodes : [])
135
+ const rows = issues.nodes.filter(isRecord).map(n => ({
136
+ number: n.number,
137
+ title: n.title,
138
+ url: n.url,
139
+ labels: inner(n.labels),
140
+ assignees: inner(n.assignees),
141
+ ...(typeof n.body === 'string' ? { body: n.body } : {}),
142
+ }))
143
+ return { rows, hasNextPage, endCursor: hasNextPage ? (endCursor as string) : null }
144
+ }
145
+
146
+ // The open `trailhead:uat` rows of the GraphQL read, as JSON (`number` and `body`): ticket number -> the parent its first
105
147
  // `UAT of:` line names, `[<name>](<url>/issues/<n>)` (or a bare `#<n>`, with an empty title). A row with
106
148
  // no such line, or an unreadable one, is skipped; not a list is unreadable (null).
107
149
  export const parseUatParents = (stdout: string): ReadonlyMap<number, UatParent> | null => {
@@ -237,7 +279,55 @@ export const parseMapsRead = (stdout: string): MapsRead | null => {
237
279
  }),
238
280
  )
239
281
  .sort((a, b) => a.number - b.number)
240
- return { maps, whiteboard: whiteboardOf(repo) }
282
+ return { maps, whiteboard: whiteboardOf(repo), isMapsCapped: isCappedBy(issues, nodes.length) }
283
+ }
284
+
285
+ // More open maps exist than the read returned (`totalCount` above the nodes read); without a usable
286
+ // `totalCount` a read that filled MAPS_READ_LIMIT is taken as cut.
287
+ const isCappedBy = (issues: Record<string, unknown>, read: number): boolean => {
288
+ const count = totalOf(issues)
289
+ return count === null ? read >= MAPS_READ_LIMIT : count > read
290
+ }
291
+
292
+ // The most open maps one maps read asks for.
293
+ export const MAPS_READ_LIMIT = 100
294
+
295
+ // One query counting the open tickets of every open map and of the whiteboard: the dashboard's own scopes
296
+ // (a ticket carries one map label or the whiteboard label, never both, so nothing counts twice).
297
+ export const openTotalQuery = (maps: readonly number[]): string => {
298
+ const scope = (alias: string, label: string) => `${alias}:issues(states:OPEN,labels:["${label}"]){totalCount}`
299
+ const fields = [...maps.map(n => scope(`m${n}`, `${LABEL_PREFIX}map-${n}`)), scope('wb', `${LABEL_PREFIX}whiteboard`)]
300
+ return `query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){${fields.join('')}}}`
301
+ }
302
+
303
+ // The sum of every alias of `openTotalQuery`; any `errors`, a missing alias or an unusable count is
304
+ // unreadable (null), never a partial sum shown as exact.
305
+ export const parseOpenTotal = (stdout: string, maps: readonly number[]): number | null => {
306
+ let raw: unknown
307
+ try {
308
+ raw = JSON.parse(stdout)
309
+ } catch {
310
+ return null
311
+ }
312
+ if (!isRecord(raw) || 'errors' in raw || !isRecord(raw.data) || !isRecord(raw.data.repository)) return null
313
+ const repo = raw.data.repository
314
+ const counts = [...maps.map(n => totalOf(repo[`m${n}`])), totalOf(repo.wb)]
315
+ return counts.some(c => c === null) ? null : counts.reduce((sum: number, c) => sum + (c as number), 0)
316
+ }
317
+
318
+ // The last good total, kept through a failed count read only while it was computed over exactly these open
319
+ // maps (and the same cut of the maps read); a changed map set hides it rather than show it stale.
320
+ export const keepOpenTotal = (kept: OpenTotal | null, maps: readonly number[], isMapsCapped: boolean): OpenTotal | null =>
321
+ kept !== null && kept.isMapsCapped === isMapsCapped && kept.maps.length === maps.length && kept.maps.every((n, i) => n === maps[i]) ? kept : null
322
+
323
+ // The line of the total, long form first, the short one when that does not fit, null when unknown or when
324
+ // not even the short one fits. A truncated maps read is a lower bound, worded as one.
325
+ export const openTotalLabel = (total: number | null, isCapped: boolean, columns: number): string | null => {
326
+ if (total === null) return null
327
+ const long = `${isCapped ? 'at least ' : ''}${total} open across all maps + whiteboard`
328
+ if (long.length <= columns) return long
329
+ const short = `${isCapped ? '≥' : ''}${total} open in total`
330
+ return short.length <= columns ? short : null
241
331
  }
242
332
 
243
333
  // `gh issue list --label trailhead:dashboard --json number,url`: the pinned index, the lowest number
@@ -322,13 +412,13 @@ export const whiteboardHeading = (tickets: readonly PaneTicket[] | null, isCappe
322
412
 
323
413
  const byNumber = (a: PaneTicket, b: PaneTicket) => a.number - b.number
324
414
 
325
- // Paused wins, then a UAT ticket (whatever its state), then the state: each ticket shows once.
326
- type Place = TicketState | 'uat'
327
- const placeOf = (t: PaneTicket): Place => (t.state === 'paused' ? 'paused' : t.isUat ? 'uat' : t.state)
415
+ // Needs-you wins, then paused, then a UAT ticket (whatever its state), then the state: each ticket shows once.
416
+ type Place = TicketState | 'uat' | 'needs-you'
417
+ const placeOf = (t: PaneTicket): Place => (t.isNeedsYou ? 'needs-you' : t.state === 'paused' ? 'paused' : t.isUat ? 'uat' : t.state)
328
418
 
329
419
  export const groupTickets = (tickets: readonly PaneTicket[]): TicketGroups => {
330
420
  const of = (place: Place) => tickets.filter(t => placeOf(t) === place).sort(byNumber)
331
- return { frontier: of('frontier'), inProgress: of('in-progress'), uat: of('uat'), paused: of('paused'), blocked: of('blocked') }
421
+ return { needsYou: of('needs-you'), frontier: of('frontier'), inProgress: of('in-progress'), uat: of('uat'), paused: of('paused'), blocked: of('blocked') }
332
422
  }
333
423
 
334
424
  const errorText = (err: unknown): string => {
@@ -358,6 +448,10 @@ export const refreshDecision = (now: number, lastStartedAt: number | null, minMs
358
448
  return waitMs <= 0 ? { run: true } : { run: false, waitMs }
359
449
  }
360
450
 
451
+ // A host timer dies silently (a refused `clock.after` never fires, a refused `every` period ends the
452
+ // interval): one whose due time is past by more than the grace is held dead and started again.
453
+ export const isTimerLate = (now: number, dueAt: number, graceMs: number): boolean => now - dueAt > graceMs
454
+
361
455
  export const ageLabel = (ms: number): string => {
362
456
  if (ms < 5_000) return 'just now'
363
457
  if (ms < 60_000) return `${Math.floor(ms / 1_000)}s ago`
@@ -365,7 +459,7 @@ export const ageLabel = (ms: number): string => {
365
459
  return `${Math.floor(ms / 3_600_000)}h ago`
366
460
  }
367
461
 
368
- export type RowAside = { kind: 'work' | 'queued' | 'holder' | 'self'; text: string }
462
+ export type RowAside = { kind: 'work' | 'resume' | 'queued' | 'starting' | 'holder' | 'self'; text: string }
369
463
  export type RowLayout = {
370
464
  mark: string | null
371
465
  label: string
@@ -379,6 +473,8 @@ export type RowContext = {
379
473
  // True when the group holds the ticket in play: its rows share a two-cell mark column.
380
474
  hasMarkColumn: boolean
381
475
  pendingWork: number | null
476
+ // Whether the pending press waits for the turn to end (`queued`) or has been sent (`starting`).
477
+ pendingPhase: 'queued' | 'starting' | null
382
478
  columns: number
383
479
  }
384
480
 
@@ -408,20 +504,49 @@ export const footerStatus = (status: string, columns: number): FooterStatus => {
408
504
 
409
505
  const SELF_ASIDE: RowAside = { kind: 'self', text: 'this session' }
410
506
 
411
- // The ticket in play says `this session`; otherwise a frontier row has its button and the rest their holder.
412
- const asideOf = (t: PaneTicket, pendingWork: number | null, isInPlay: boolean): RowAside | null => {
507
+ type Pending = { number: number | null; phase: 'queued' | 'starting' | null }
508
+ // Only a press known to wait for the turn to end is offered a cancel; one sent, or not yet classed, is not.
509
+ const pendingAside = (phase: Pending['phase']): RowAside =>
510
+ phase === 'queued' ? { kind: 'queued', text: 'queued' } : { kind: 'starting', text: 'starting' }
511
+
512
+ const holderOf = (t: PaneTicket): RowAside => ({ kind: 'holder', text: t.assignees.map(a => `@${a}`).join(' ') })
513
+
514
+ // A needs-you row: any holder first (a `work` press would steal the claim), then nothing when blocked,
515
+ // else `work` (never `resume`: the claim clears the mark, resume would not).
516
+ const needsYouAside = (t: PaneTicket, pending: Pending): RowAside | null => {
517
+ if (t.assignees.length > 0) return holderOf(t)
518
+ if (t.state === 'blocked') return null
519
+ return pending.number === t.number ? pendingAside(pending.phase) : { kind: 'work', text: 'work' }
520
+ }
521
+
522
+ // The ticket in play says `this session`; a needs-you row follows its own rule; otherwise a frontier row has
523
+ // a `work` button, a paused one a `resume` button (the verb checks the claim), and the rest their holder.
524
+ const asideOf = (t: PaneTicket, pending: Pending, isInPlay: boolean): RowAside | null => {
413
525
  if (isInPlay) return SELF_ASIDE
414
- if (t.state === 'frontier') return pendingWork === t.number ? { kind: 'queued', text: 'queued' } : { kind: 'work', text: 'work' }
415
- return t.assignees.length === 0 ? null : { kind: 'holder', text: t.assignees.map(a => `@${a}`).join(' ') }
526
+ if (t.isNeedsYou) return needsYouAside(t, pending)
527
+ if (t.state === 'frontier' || t.state === 'paused') {
528
+ if (pending.number === t.number) return pendingAside(pending.phase)
529
+ return t.state === 'frontier' ? { kind: 'work', text: 'work' } : { kind: 'resume', text: 'resume' }
530
+ }
531
+ return t.assignees.length === 0 ? null : holderOf(t)
416
532
  }
417
533
 
534
+ // The button that takes a queued ticket back out of the queue.
535
+ export const CANCEL_LABEL = 'cancel'
536
+ const CANCEL_WIDTH = CANCEL_LABEL.length + BUTTON_CHROME + 1
537
+
418
538
  const asideWidth = (a: RowAside | null): number =>
419
- a === null ? 0 : a.text.length + (a.kind === 'work' ? BUTTON_CHROME : 0) + 1
539
+ a === null
540
+ ? 0
541
+ : a.text.length + (a.kind === 'work' || a.kind === 'resume' ? BUTTON_CHROME : 0) + 1 + (a.kind === 'queued' ? CANCEL_WIDTH : 0)
542
+
543
+ // The press the panel holds back while a turn runs, and releases when it ends.
544
+ export type HeldPress = { id: number; verb: 'work' | 'resume'; number: number }
420
545
 
421
546
  // What one row draws, cut to the pane width: mark column, `#n title`, type, right-hand part.
422
547
  export const rowLayout = (t: PaneTicket, ctx: RowContext): RowLayout => {
423
548
  const mark = ctx.isInPlay ? MARK : ctx.hasMarkColumn ? MARK_PAD : null
424
- const aside = asideOf(t, ctx.pendingWork, ctx.isInPlay)
549
+ const aside = asideOf(t, { number: ctx.pendingWork, phase: ctx.pendingPhase }, ctx.isInPlay)
425
550
  const type = t.type === null ? null : ` ${t.type}`
426
551
  const prefix = `#${t.number}`
427
552
  const room = ctx.columns - (mark?.length ?? 0) - asideWidth(aside) - (type?.length ?? 0) - prefix.length - 1