@marcomigozzi/trailhead 0.15.0 → 0.16.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 (96) hide show
  1. package/README.it.md +45 -9
  2. package/README.md +46 -10
  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/grilling-holistic-review-docs.test.js +90 -0
  12. package/bin/lib/codex-projection.js +5 -5
  13. package/bin/lib/codex-projection.test.js +2 -2
  14. package/bin/needs-you-docs.test.js +41 -0
  15. package/bin/pick-stress-test-docs.test.js +105 -0
  16. package/bin/trailhead.js +3 -3
  17. package/bin/trailhead.test.js +17 -18
  18. package/package.json +1 -1
  19. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  20. package/plugins/trailhead/commands/auto.md +2 -2
  21. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  22. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  23. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  24. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  25. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  26. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  27. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  28. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  29. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  30. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  31. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  32. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  33. package/plugins/trailhead/hooks/mod/README.md +180 -39
  34. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  35. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  36. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  37. package/plugins/trailhead/hooks/mod/map-pane.tsx +526 -92
  38. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  39. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  40. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  41. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  42. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  43. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  44. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  45. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  46. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  47. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  48. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  49. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  50. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  51. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  52. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  53. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  54. package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
  55. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  56. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  57. package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
  58. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  59. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  60. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  61. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  62. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  63. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +4 -4
  64. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  65. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  66. package/plugins/trailhead/skills/trailhead-work/SKILL.md +21 -10
  67. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  68. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
  69. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  70. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  71. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  72. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  73. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  74. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  75. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  76. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  77. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  78. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  79. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  80. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  81. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  82. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  83. package/plugins/trailhead/tests/map-pane-ui.test.ts +1732 -142
  84. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  85. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  86. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  87. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  88. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  89. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  90. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  91. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  92. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  93. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  94. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  95. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  96. package/plugins/trailhead/types/index.d.ts +26 -1
@@ -0,0 +1,165 @@
1
+ // The autonomous run seen from the main checkout (#296): which slots are in flight and the scope counts.
2
+ // Read-only: the mod only watches, it never writes a work state or the tracker. Pure parts are in
3
+ // auto-run-view.ts; here the engine part that scans the checkouts and reads the counts.
4
+ import type { EngineInterface } from 'claude-code'
5
+
6
+ import type { AutoRun, RepoPin, RunCounts, WorkState } from '../../types'
7
+ import { GH_TIMEOUT_MS, MIN_REFRESH_MS } from './map-pane'
8
+ import { aggregateRun, isRunMember, nextRunAfterFailure, parseRunCounts, parseRunScope, runCountsArgs, slotTicket } from './auto-run-view'
9
+ import type { Where } from './auto-run-view'
10
+ import { pinKey } from './repo-pin'
11
+ import { listSiblings, listWorktreeBranches, readAt, trim } from './work-state-locate'
12
+ import { isMissing, parseWorkState } from './work-state-view'
13
+
14
+ const RECORD_PATH = '.trailhead/auto-run.json'
15
+ const GIT_TIMEOUT_MS = 5_000
16
+
17
+ // L'ultimo run noto e i fallimenti di fila (con l'istante dell'ultimo contato): sono macchinario del modulo, non stato disegnato.
18
+ let lastRun: AutoRun | null = null
19
+ let failures = 0
20
+ let lastFailureAt: number | null = null
21
+ let counts: { key: string; at: number; value: RunCounts | null } | null = null
22
+
23
+ export const resetAutoRun = (): void => {
24
+ lastRun = null
25
+ failures = 0
26
+ lastFailureAt = null
27
+ counts = null
28
+ }
29
+
30
+ // Il run sparisce: anche i conteggi e il loro pavimento.
31
+ export const clearRunCounts = (): void => {
32
+ counts = null
33
+ }
34
+
35
+ type Gate = { kind: 'closed' } | { kind: 'open'; scope: string | null } | { kind: 'failed' }
36
+
37
+ // Il run può esistere solo se c'è il record o lo stato proprio è auto: altrimenti nessun processo parte.
38
+ async function gate($: EngineInterface, main: string, own: WorkState | null): Promise<Gate> {
39
+ try {
40
+ const text = (await $.fs.read(`${trim(main)}/${RECORD_PATH}`)) as string
41
+ return { kind: 'open', scope: parseRunScope(text) }
42
+ } catch (err) {
43
+ if (!isMissing(err)) return own?.auto === true ? { kind: 'open', scope: null } : { kind: 'failed' }
44
+ return own?.auto === true ? { kind: 'open', scope: null } : { kind: 'closed' }
45
+ }
46
+ }
47
+
48
+ // La regola di cloneOnBranch (trailhead-auto-slot.js): git dir dentro la cartella e HEAD sul branch dello slot.
49
+ // undefined: git non è riuscito a girare.
50
+ async function cloneOnBranch($: EngineInterface, dir: string, ticket: number): Promise<boolean | undefined> {
51
+ const git = (args: string[]) => $.process.run(['git', '-C', dir, ...args], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS })
52
+ try {
53
+ const g = await git(['rev-parse', '--absolute-git-dir'])
54
+ if (g.exitCode !== 0) return false
55
+ const gitDir = trim(g.stdout.trim())
56
+ if (gitDir !== dir && !gitDir.startsWith(`${dir}/`)) return false
57
+ const b = await git(['symbolic-ref', '--quiet', 'HEAD'])
58
+ return b.exitCode === 0 && b.stdout.trim() === `refs/heads/trailhead/t${ticket}`
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+
64
+ type Candidate = { path: string; where: Where | { kind: 'sibling'; ticket: number } }
65
+
66
+ // Un checkout alla volta, senza doppioni: vince la grafia di git, il main e il pin contano come main.
67
+ const candidatesOf = (main: string, pinned: string, listed: readonly { path: string; branch: string | null }[], siblings: readonly string[]): Candidate[] => {
68
+ const all: Candidate[] = [{ path: trim(main), where: { kind: 'main' } }]
69
+ if (trim(pinned) !== trim(main)) all.push({ path: trim(pinned), where: { kind: 'main' } })
70
+ for (const w of listed) {
71
+ const ticket = slotTicket(w.branch)
72
+ if (ticket !== null) all.push({ path: trim(w.path), where: { kind: 'worktree', ticket } })
73
+ }
74
+ for (const path of siblings) {
75
+ const ticket = Number(/-t(\d+)$/.exec(path)?.[1])
76
+ if (ticket > 0) all.push({ path: trim(path), where: { kind: 'sibling', ticket } })
77
+ }
78
+ return all.filter((c, i) => all.findIndex(o => o.path === c.path) === i)
79
+ }
80
+
81
+ type Scan = { ok: true; run: AutoRun | null } | { ok: false }
82
+
83
+ async function scan($: EngineInterface, main: string, pinned: string): Promise<Scan> {
84
+ const listed = await listWorktreeBranches($, main)
85
+ const siblings = await listSiblings($, main)
86
+ if (listed === null || siblings === null) return { ok: false }
87
+ const candidates = candidatesOf(main, pinned, listed, siblings)
88
+ const texts = await Promise.all(candidates.map(c => readAt($, c.path)))
89
+ if (texts.includes(undefined)) return { ok: false }
90
+ const members: WorkState[] = []
91
+ for (const [i, c] of candidates.entries()) {
92
+ const text = texts[i]
93
+ const state = typeof text === 'string' ? parseWorkState(text) : null
94
+ if (state === null || !state.auto) continue
95
+ let where: Where
96
+ if (c.where.kind === 'sibling') {
97
+ // Il controllo git costa due processi: solo per un fratello il cui stato è già auto del suo ticket.
98
+ if (state.ticket !== c.where.ticket) continue
99
+ const onBranch = await cloneOnBranch($, c.path, c.where.ticket)
100
+ if (onBranch === undefined) return { ok: false }
101
+ where = { kind: 'clone', ticket: c.where.ticket, onBranch }
102
+ } else where = c.where
103
+ if (isRunMember(where, state)) members.push(state)
104
+ }
105
+ return { ok: true, run: aggregateRun(members) }
106
+ }
107
+
108
+ // Un turno fa più refresh: un fallimento conta solo se dall'ultimo contato è passato MIN_REFRESH_MS,
109
+ // così un singhiozzo breve di git non azzera un run vivo. Senza orologio ogni fallimento conta.
110
+ async function isNewFailure($: EngineInterface): Promise<boolean> {
111
+ const now = await nowOf($)
112
+ if (now === null) return true
113
+ if (lastFailureAt !== null && now - lastFailureAt < MIN_REFRESH_MS) return false
114
+ lastFailureAt = now
115
+ return true
116
+ }
117
+
118
+ // Il run attuale e lo scope del record. Una scansione fallita tiene l'ultimo risultato per al massimo
119
+ // MAX_FAILURES volte di fila; un miss pulito o il gate chiuso lo azzerano subito.
120
+ export async function scanRun($: EngineInterface, input: { main: string; pinned: string; ownState: WorkState | null }): Promise<{ run: AutoRun | null; scope: string | null }> {
121
+ const g = await gate($, input.main, input.ownState)
122
+ if (g.kind === 'closed') {
123
+ lastRun = null
124
+ failures = 0
125
+ lastFailureAt = null
126
+ return { run: null, scope: null }
127
+ }
128
+ const result = g.kind === 'open' ? await scan($, trim(input.main), input.pinned) : ({ ok: false } as const)
129
+ if (result.ok) {
130
+ failures = 0
131
+ lastFailureAt = null
132
+ lastRun = result.run
133
+ } else if (await isNewFailure($)) {
134
+ failures += 1
135
+ lastRun = nextRunAfterFailure(lastRun, failures)
136
+ }
137
+ return { run: lastRun, scope: g.kind === 'open' ? g.scope : null }
138
+ }
139
+
140
+ async function nowOf($: EngineInterface): Promise<number | null> {
141
+ try {
142
+ return await $.clock.now()
143
+ } catch {
144
+ return null
145
+ }
146
+ }
147
+
148
+ // I conteggi dello scope, mai più spesso di MIN_REFRESH_MS; una lettura fallita tiene gli ultimi.
149
+ export async function readRunCounts($: EngineInterface, pin: RepoPin, scope: string | null): Promise<RunCounts | null> {
150
+ const key = `${pinKey(pin)}|${scope ?? ''}`
151
+ const now = await nowOf($)
152
+ const cached = counts !== null && counts.key === key ? counts : null
153
+ // Senza orologio non c'è pavimento: niente gh a ruota libera, vale il valore in cache (o nulla).
154
+ if (now === null) return cached?.value ?? null
155
+ if (cached !== null && now - cached.at < MIN_REFRESH_MS) return cached.value
156
+ let value = cached?.value ?? null
157
+ try {
158
+ const out = await $.process.run(['gh', ...runCountsArgs(pin.host, pin.slug, scope)], { cwd: pin.root, timeoutMs: GH_TIMEOUT_MS })
159
+ if (out.exitCode === 0) value = parseRunCounts(out.stdout) ?? value
160
+ } catch {
161
+ // Lettura fallita: restano gli ultimi conteggi.
162
+ }
163
+ counts = { key, at: now, value }
164
+ return value
165
+ }
@@ -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