@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
@@ -1,31 +1,40 @@
1
1
  // The map pane (#208): a docked Pane listing the active map's open tickets by state.
2
- // It reads the active map from `.trailhead/active-map` and the tickets through `gh`, only
3
- // while the pane is open, and never faster than MIN_REFRESH_MS. Claude Code 2.1.287+ only.
2
+ // It reads the active map from `.trailhead/active-map` and the tickets through `gh`, both under the
3
+ // pinned repo (repo-pin.ts), not the session's cwd, only while the pane is open, and never faster than MIN_REFRESH_MS. Claude Code 2.1.287+ only.
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, MapSummary, PaneTicket, UatParent, WorkState } from '../../types'
7
+ import type { BurndownHistory, DashboardPaneData, MapCounts, MapPaneData, MapsRead, MapSummary, OpenTotal, PaneTicket, RepoPin, UatParent, WorkState } from '../../types'
8
8
  import {
9
9
  ageLabel,
10
10
  axisStart,
11
+ CANCEL_LABEL,
11
12
  type BurndownSeries,
12
13
  burndownEnd,
13
14
  burndownSeries,
14
15
  failureReason,
15
16
  footerStatus,
16
17
  groupTickets,
18
+ type HeldPress,
17
19
  headingRoom,
18
20
  HISTORY_LIMIT,
21
+ isTimerLate,
22
+ keepOpenTotal,
19
23
  LIST_LIMIT,
20
24
  MAP_LABEL,
25
+ MAPS_READ_LIMIT,
21
26
  mapRowLayout,
22
27
  nextViewed,
28
+ openTotalLabel,
29
+ openTotalQuery,
23
30
  parseActiveMap,
24
31
  parseDashboard,
25
32
  parseHistory,
33
+ parseIssuesPage,
26
34
  parseMap,
27
35
  parseMaps,
28
36
  parseMapsRead,
37
+ parseOpenTotal,
29
38
  parseTickets,
30
39
  parseUatParents,
31
40
  rasterCells,
@@ -37,7 +46,11 @@ import {
37
46
  withUatParents,
38
47
  whiteboardHeading,
39
48
  whiteboardRowLayout,
49
+ withLabel,
40
50
  } from './map-pane-view'
51
+ import { checkoutArg, currentRepo, isPinCheckout, isSamePin, pinKey, pinPath, refreshPin, toplevelOf } from './repo-pin'
52
+ import { isMainTurnRunning } from './turn-state'
53
+ import { createdIssueNumber, isWhiteboardCreate, nextFollow, whiteboardHint } from './whiteboard-follow'
41
54
  import { fit, isMissing, sameValue } from './work-state-view'
42
55
 
43
56
  export const PANE_ID = 'trailhead-map'
@@ -46,16 +59,26 @@ export const PANEL_COMMAND = 'trailhead:panel'
46
59
  export const MIN_REFRESH_MS = 15_000
47
60
  export const TIMER_MS = 180_000
48
61
  export const GH_TIMEOUT_MS = 20_000
62
+ // How late past its due time a timer may fire before it is held dead (isTimerLate) and started again.
63
+ export const TIMER_GRACE_MS = 30_000
49
64
  // A list unchanged for this long is still written, so its age label moves on.
50
65
  const AGE_REFRESH_MS = 60_000
66
+ // Shown when no GitHub repo was ever pinned; the pane then makes no `gh` call.
67
+ const NO_REPO = 'Not in a GitHub repository: open the session in a trailhead repo'
51
68
  const ACTIVE_MAP_PATH = '.trailhead/active-map'
52
69
  const TICKET_FIELDS = 'number,title,labels,assignees,url'
53
- const UAT_FIELDS = 'number,body'
54
70
  const MAP_FIELDS = 'title,body,url'
55
71
  const HISTORY_FIELDS = 'number,labels,createdAt,closedAt'
56
72
  const MAPS_FIELDS = 'number,title,url'
57
- const MAPS_LIMIT = 100
58
- const MAPS_QUERY = `query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){issues(first:${MAPS_LIMIT},states:OPEN,labels:["trailhead:map"]){nodes{number title url subIssuesSummary{total completed}}}whiteboardOpen:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}whiteboardAll:issues(labels:["trailhead:whiteboard"]){totalCount}}}`
73
+ const MAPS_LIMIT = MAPS_READ_LIMIT
74
+ const PAGE_SIZE = 100
75
+ const MAPS_QUERY = `query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){issues(first:${MAPS_LIMIT},states:OPEN,labels:["trailhead:map"]){nodes{number title url subIssuesSummary{total completed}}totalCount}whiteboardOpen:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}whiteboardAll:issues(labels:["trailhead:whiteboard"]){totalCount}}}`
76
+ // The open tickets of one label, newest first like `gh issue list`; paged by the module (PAGE_SIZE a page).
77
+ const TICKETS_QUERY = `query($owner:String!,$repo:String!,$label:String!,$after:String){repository(owner:$owner,name:$repo){issues(first:${PAGE_SIZE},after:$after,states:OPEN,labels:[$label],orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url labels(first:50){nodes{name}}assignees(first:10){nodes{login}}}pageInfo{hasNextPage endCursor}}}}`
78
+ // Every open UAT follow-up with its body and labels, newest first; paged by the module (PAGE_SIZE a page, at
79
+ // most UAT_MAX_PAGES pages). The scope is filtered by the caller: GraphQL's `labels` is any-of.
80
+ 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
+ const UAT_MAX_PAGES = 10
59
82
  const DASHBOARD_FIELDS = 'number,url'
60
83
  // Same colours as the band.
61
84
  const ACCENT = '#7aa2f7'
@@ -71,6 +94,7 @@ const EMPTY: MapPaneData = {
71
94
  tickets: [],
72
95
  maps: [],
73
96
  whiteboard: null,
97
+ openTotal: null,
74
98
  unverifiedCount: 0,
75
99
  isCapped: false,
76
100
  history: null,
@@ -98,23 +122,32 @@ export const viewedMap = atom({ plugin: 'trailhead', key: 'viewedMap' } as const
98
122
  // The dashboard view (maps index + whiteboard tickets) in place of a map; a map press leaves it.
99
123
  export const isDashboard = atom({ plugin: 'trailhead', key: 'isDashboard' } as const, false)
100
124
  export const dashboardPane = atom({ plugin: 'trailhead', key: 'dashboardPane' } as const, EMPTY_DASHBOARD)
101
- // The ticket whose `work` press is queued; one at a time.
125
+ // The ticket whose `work` or `resume` press is queued; one at a time.
102
126
  export const pendingWork = atom({ plugin: 'trailhead', key: 'pendingWork' } as const, null as number | null)
127
+ // Whether that press waits for the running turn to end (cancellable) or was sent to the host.
128
+ export const pendingPhase = atom({ plugin: 'trailhead', key: 'pendingPhase' } as const, null as 'queued' | 'starting' | null)
103
129
  // Set once the pane was opened by the command, by itself or closed by the person: never auto-opened after.
104
130
  export const paneAutoOpened = atom({ plugin: 'trailhead', key: 'paneAutoOpened' } as const, false)
105
131
  const PENDING = { plugin: 'trailhead', key: 'pendingWork' } as const
106
132
  const WORK_COMMAND = 'trailhead:work'
133
+ const RESUME_COMMAND = 'trailhead:resume'
107
134
  // The built-in that empties the conversation; the work verb starts in a clean one.
108
135
  const CLEAR_COMMAND = 'clear'
109
136
  // The same refs the band's atoms are written under; read here to mark the ticket in play.
110
137
  const workState = atom({ plugin: 'trailhead', key: 'workState' } as const, null as WorkState | null)
111
138
  const isOrphaned = atom({ plugin: 'trailhead', key: 'isOrphaned' } as const, false)
112
139
  const ownTicket = atom({ plugin: 'trailhead', key: 'ownTicket' } as const, null as number | null)
140
+ // The repo pin refreshPin keeps (repo-pin.ts). The module scan wants the atom spelled in the file that reads it,
141
+ // so this copy and the one in repo-pin.ts must keep the same { plugin, key }.
142
+ const repoPin = atom({ plugin: 'trailhead', key: 'repoPin' } as const, null as RepoPin | null)
113
143
 
114
144
  type Dollar = EngineInterface
115
145
  type Timer = { cancel: () => void }
146
+ // The slow timer with the time it should fire next: the host drops a refused period silently, so the handle
147
+ // alone cannot tell a live timer from a dead one.
148
+ type Tracked = { timer: Timer; dueAt: number }
116
149
 
117
- const debug = ($: Dollar, message: string) => {
150
+ function debug($: Dollar, message: string): void {
118
151
  try {
119
152
  $.ui.log(message, { to: 'debug' })
120
153
  } catch {
@@ -128,17 +161,28 @@ let queue: Promise<void> = Promise.resolve()
128
161
  let generation = 0
129
162
  let lastStartedAt: number | null = null
130
163
  let trailing: Timer | null = null
131
- let ticker: Timer | null = null
164
+ let ticker: Tracked | null = null
132
165
  // What /clear would take from the pane, kept across it (module variables survive it, atoms do not).
133
166
  type Carried = { isAutoOpened: boolean; viewed: number | null; active: number | null; isDashboard: boolean }
134
167
  let carried: Carried | null = null
135
- // The ticket whose `work` press is running: /clear empties the pendingWork atom meanwhile.
136
- let workInFlight: number | null = null
168
+ // A `work` or `resume` press made during a running turn: the panel keeps it, and sends nothing, until the
169
+ // main turn ends. One slot, shared with `inFlight` (the pendingWork claim): never both at once.
170
+ let held: HeldPress | null = null
171
+ // The id of the press whose /clear and verb are being sent: /clear empties the pendingWork atom meanwhile.
172
+ let inFlight: number | null = null
173
+ // The press being sent, until its /clear is called: a cancel marks it and the dispatch drops it at that line.
174
+ let dispatching: { id: number; number: number; isCancelled: boolean; isSent: boolean } | null = null
175
+ // The number of a press between its claim and the choice to hold it or send it.
176
+ let claiming: { number: number; isCancelled: boolean } | null = null
177
+ let pressCount = 0
137
178
 
138
179
  async function readActiveMap($: Dollar): Promise<number | null> {
180
+ const pin = await read($, repoPin)
181
+ // No repo pinned: there is nothing to read the marker from.
182
+ if (pin === null) return read($, activeMap)
139
183
  let next: number | null
140
184
  try {
141
- next = parseActiveMap((await $.fs.read(ACTIVE_MAP_PATH)) as string)
185
+ next = parseActiveMap((await $.fs.read(pinPath(pin, ACTIVE_MAP_PATH))) as string)
142
186
  } catch (err) {
143
187
  // A missing marker means no active map; any other failure is transient: keep the last one.
144
188
  if (!isMissing(err)) return read($, activeMap)
@@ -158,41 +202,93 @@ async function setPane($: Dollar, next: MapPaneData) {
158
202
 
159
203
  type GhResult = { ok: true; stdout: string } | { ok: false; reason: string }
160
204
 
161
- async function gh($: Dollar, args: readonly string[]): Promise<GhResult> {
205
+ // Always run in the pinned repo, so the session's cwd moving out of it never changes what is read.
206
+ async function gh($: Dollar, pin: RepoPin, args: readonly string[]): Promise<GhResult> {
162
207
  try {
163
- const out = await $.process.run(['gh', ...args], { timeoutMs: GH_TIMEOUT_MS })
208
+ const out = await $.process.run(['gh', ...args], { cwd: pin.root, timeoutMs: GH_TIMEOUT_MS })
164
209
  return out.exitCode === 0 ? { ok: true, stdout: out.stdout } : { ok: false, reason: failureReason(out) }
165
210
  } catch (error) {
166
211
  return { ok: false, reason: failureReason({ error }) }
167
212
  }
168
213
  }
169
214
 
170
- const readList = ($: Dollar, target: number) =>
215
+ const readList = ($: Dollar, pin: RepoPin, target: number, kept: OpenTotal | null) =>
171
216
  Promise.all([
172
- gh($, ['issue', 'view', String(target), '--json', MAP_FIELDS]),
173
- gh($, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS]),
174
- gh($, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'all', '--limit', String(HISTORY_LIMIT), '--json', HISTORY_FIELDS]),
175
- readMaps($),
176
- readUat($, `trailhead:map-${target}`),
217
+ gh($, pin, ['issue', 'view', String(target), '--json', MAP_FIELDS]),
218
+ readOpen($, pin, `trailhead:map-${target}`),
219
+ gh($, pin, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'all', '--limit', String(HISTORY_LIMIT), '--json', HISTORY_FIELDS]),
220
+ readMaps($, pin, kept),
221
+ readUat($, pin, `trailhead:map-${target}`),
177
222
  ])
178
223
 
179
- // The pending UAT tickets of one scope (a map label, or the whiteboard) with their bodies, for the parent
180
- // each one tests. A failed or unreadable read is null: the rows still group by label, only the parent line goes.
181
- const readUat = async ($: Dollar, scope: string): Promise<ReadonlyMap<number, UatParent> | null> => {
182
- const out = await gh($, ['issue', 'list', '--label', 'trailhead:uat', '--label', scope, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', UAT_FIELDS])
183
- return out.ok ? parseUatParents(out.stdout) : null
224
+ // The open tickets of one label as the JSON array `gh issue list --json` prints, so one parser reads both.
225
+ // `gh issue list --label` goes through the search API, which lags a write by seconds; the GraphQL
226
+ // `repository.issues` read does not, so a ticket just created or closed shows at once. Paged up to LIST_LIMIT
227
+ // rows (`also`: a second label the rows must carry, GraphQL's `labels` being any-of). A page that fails or is
228
+ // unreadable discards the whole GraphQL read (never a partial list) and the `gh issue list` runs once instead.
229
+ const readOpen = async ($: Dollar, pin: RepoPin, label: string, also?: string): Promise<GhResult> => {
230
+ const rows: unknown[] = []
231
+ let cursor: string | null = null
232
+ while (rows.length < LIST_LIMIT) {
233
+ // The {owner}/{repo} placeholders come from the pinned root's remote, as in readMaps.
234
+ const args = ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${TICKETS_QUERY}`, '-f', `label=${label}`]
235
+ const out = await gh($, pin, cursor === null ? args : [...args, '-f', `after=${cursor}`])
236
+ const page = out.ok ? parseIssuesPage(out.stdout) : null
237
+ if (page === null) return readOpenFallback($, pin, label, also)
238
+ rows.push(...page.rows)
239
+ if (!page.hasNextPage) break
240
+ cursor = page.endCursor
241
+ }
242
+ return { ok: true, stdout: JSON.stringify(also === undefined ? rows : withLabel(rows, also)) }
243
+ }
244
+
245
+ const readOpenFallback = ($: Dollar, pin: RepoPin, label: string, also?: string) =>
246
+ gh($, pin, ['issue', 'list', ...(also === undefined ? [] : ['--label', also]), '--label', label, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS])
247
+
248
+ // The pending UAT tickets of one scope (a map label, or the whiteboard) with their bodies, for the parent each
249
+ // one tests. Read through GraphQL (the search index lags a write by seconds, so a follow-up just opened would
250
+ // show without its parent line), all open `trailhead:uat` tickets, the scope filtered here. Fail-closed: a page
251
+ // that fails or is unreadable, or pages left at UAT_MAX_PAGES, make the read null (never a partial map, no search
252
+ // fallback). Null is the failure value: the rows still group by label, only the parent line goes.
253
+ const readUat = async ($: Dollar, pin: RepoPin, scope: string): Promise<ReadonlyMap<number, UatParent> | null> => {
254
+ const rows: unknown[] = []
255
+ let cursor: string | null = null
256
+ for (let n = 0; n < UAT_MAX_PAGES; n++) {
257
+ // The {owner}/{repo} placeholders come from the pinned root's remote, as in readMaps.
258
+ const args = ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${UAT_QUERY}`]
259
+ const out = await gh($, pin, cursor === null ? args : [...args, '-f', `after=${cursor}`])
260
+ const page = out.ok ? parseIssuesPage(out.stdout) : null
261
+ if (page === null) return null
262
+ rows.push(...page.rows)
263
+ if (!page.hasNextPage) return parseUatParents(JSON.stringify(withLabel(rows, scope)))
264
+ cursor = page.endCursor
265
+ }
266
+ return null
267
+ }
268
+
269
+ // The open maps of the repo, the whiteboard's counts and the open-ticket total across them. A failed or
270
+ // unreadable maps read is null: the caller keeps the last good list. One GraphQL call gives every map with its
271
+ // sub-issue counts and the whiteboard's; if it fails or is unreadable the name-only `gh issue list` runs instead
272
+ // (counts null, the whiteboard null: the row is drawn name-only, never stale; the total null too, as a sum over a
273
+ // list of unknown completeness is never shown). Both bounded at MAPS_LIMIT open maps.
274
+ type MapsResult = { maps: readonly MapSummary[] | null; whiteboard: MapCounts | null; openTotal: OpenTotal | null }
275
+
276
+ // The total over the open maps just read, one aliased `totalCount` query. A failed or unreadable count keeps
277
+ // `kept` only while it was computed over exactly these maps (and the same cut), else it is hidden, never stale.
278
+ const readOpenTotal = async ($: Dollar, pin: RepoPin, read: MapsRead, kept: OpenTotal | null): Promise<OpenTotal | null> => {
279
+ const maps = read.maps.map(m => m.number)
280
+ const out = await gh($, pin, ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${openTotalQuery(maps)}`])
281
+ const total = out.ok ? parseOpenTotal(out.stdout, maps) : null
282
+ return total === null ? keepOpenTotal(kept, maps, read.isMapsCapped) : { total, maps, isMapsCapped: read.isMapsCapped }
184
283
  }
185
284
 
186
- // The open maps of the repo and the whiteboard's counts. A failed or unreadable maps read is null: the
187
- // caller keeps the last good list. One GraphQL call gives every map with its sub-issue counts and the
188
- // whiteboard's; if it fails or is unreadable the name-only `gh issue list` runs instead (counts null, and
189
- // the whiteboard null: the row is drawn name-only, never stale). Both bounded at MAPS_LIMIT open maps.
190
- const readMaps = async ($: Dollar): Promise<{ maps: readonly MapSummary[] | null; whiteboard: MapCounts | null }> => {
191
- const counted = await gh($, ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MAPS_QUERY}`])
285
+ const readMaps = async ($: Dollar, pin: RepoPin, kept: OpenTotal | null): Promise<MapsResult> => {
286
+ // The {owner}/{repo} placeholders come from the pinned root's remote, which gh resolves with its host too.
287
+ const counted = await gh($, pin, ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MAPS_QUERY}`])
192
288
  const withCounts = counted.ok ? parseMapsRead(counted.stdout) : null
193
- if (withCounts !== null) return withCounts
194
- const out = await gh($, ['issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', String(MAPS_LIMIT), '--json', MAPS_FIELDS])
195
- return { maps: out.ok ? parseMaps(out.stdout) : null, whiteboard: null }
289
+ if (withCounts !== null) return { maps: withCounts.maps, whiteboard: withCounts.whiteboard, openTotal: await readOpenTotal($, pin, withCounts, kept) }
290
+ const out = await gh($, pin, ['issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', String(MAPS_LIMIT), '--json', MAPS_FIELDS])
291
+ return { maps: out.ok ? parseMaps(out.stdout) : null, whiteboard: null, openTotal: null }
196
292
  }
197
293
 
198
294
  // What the pane compares to skip a write: times aside, but whether the history is as fresh as the list.
@@ -212,12 +308,17 @@ async function dropClosedView($: Dollar, maps: readonly MapSummary[] | null) {
212
308
 
213
309
  // One read of the viewed (else active) map. The result is dropped when the pane closed meanwhile
214
310
  // (generation) or the target moved on, checked before every write; a moved target is read again.
215
- async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
311
+ async function fetchMap($: Dollar, pin: RepoPin, token: number, wantsAnother: () => void) {
216
312
  const target = await readTarget($)
217
313
  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.
218
317
  const isStale = async () => {
219
318
  if (token !== generation) return true
220
- if (!(await read($, isDashboard)) && (await readTarget($)) === target) return false
319
+ const isMoved = !isSamePin(await read($, repoPin), pin) || (await read($, isDashboard)) || (await readTarget($)) !== target
320
+ if (token !== generation) return true
321
+ if (!isMoved) return false
221
322
  wantsAnother()
222
323
  return true
223
324
  }
@@ -225,35 +326,35 @@ async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
225
326
 
226
327
  if (target === null) {
227
328
  // No map to show: the open maps alone, so one can be picked.
228
- const mapsRead = await readMaps($)
329
+ const mapsRead = await readMaps($, pin, before.openTotal)
229
330
  const maps = mapsRead.maps ?? before.maps
230
331
  if (await isStale()) return
231
- return setPane($, { ...EMPTY, maps, whiteboard: mapsRead.whiteboard })
332
+ return setPane($, { ...EMPTY, maps, whiteboard: mapsRead.whiteboard, openTotal: mapsRead.openTotal })
232
333
  }
233
334
  const isSameMap = before.map?.number === target
234
335
  if (!isSameMap) {
235
336
  if (await isStale()) return
236
- await setPane($, { ...EMPTY, maps: before.maps, whiteboard: before.whiteboard, isLoading: true })
337
+ await setPane($, { ...EMPTY, maps: before.maps, whiteboard: before.whiteboard, openTotal: before.openTotal, isLoading: true })
237
338
  }
238
339
 
239
- const [view, list, historyRead, mapsRead, uatParents] = await readList($, target)
340
+ const [view, list, historyRead, mapsRead, uatParents] = await readList($, pin, target, before.openTotal)
240
341
  const maps = mapsRead.maps ?? before.maps
241
- const { whiteboard } = mapsRead
342
+ const { whiteboard, openTotal } = mapsRead
242
343
  await dropClosedView($, mapsRead.maps)
243
344
  const now = await $.clock.now()
244
345
  if (await isStale()) return
245
346
 
246
347
  const kept = isSameMap ? before : EMPTY
247
348
  const failure = !view.ok ? view.reason : !list.ok ? list.reason : null
248
- if (failure !== null) return setPane($, { ...kept, maps, whiteboard, error: failure, isLoading: false })
349
+ if (failure !== null) return setPane($, { ...kept, maps, whiteboard, openTotal, error: failure, isLoading: false })
249
350
  const map = view.ok ? parseMap(view.stdout, target) : null
250
351
  const tickets = list.ok ? parseTickets(list.stdout) : null
251
- if (map === null || tickets === null) return setPane($, { ...kept, maps, whiteboard, error: 'gh returned unreadable output', isLoading: false })
352
+ if (map === null || tickets === null) return setPane($, { ...kept, maps, whiteboard, openTotal, error: 'gh returned unreadable output', isLoading: false })
252
353
 
253
354
  // A history that failed alone keeps the last good one of this map (with its own time), never another map's.
254
355
  const history = historyRead.ok ? parseHistory(historyRead.stdout) : null
255
356
  const historyPart = history !== null ? { history, historyAt: now } : { history: kept.history, historyAt: kept.historyAt }
256
- const next: MapPaneData = { map, ...tickets, tickets: withUatParents(tickets.tickets, uatParents), maps, whiteboard, ...historyPart, fetchedAt: now, error: null, isLoading: false }
357
+ const next: MapPaneData = { map, ...tickets, tickets: withUatParents(tickets.tickets, uatParents), maps, whiteboard, openTotal, ...historyPart, fetchedAt: now, error: null, isLoading: false }
257
358
  const isFresh = before.fetchedAt !== null && now - before.fetchedAt < AGE_REFRESH_MS
258
359
  if (isFresh && isSameMap && sameValue(shape(before), shape(next))) return
259
360
  await setPane($, next)
@@ -261,27 +362,31 @@ async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
261
362
 
262
363
  // The whiteboard's open tickets (map-less) and the pinned index, plus the open maps. Same drop rule
263
364
  // as a map read: a result is not committed once the pane closed or the dashboard was left.
264
- async function fetchDashboard($: Dollar, token: number, wantsAnother: () => void) {
365
+ async function fetchDashboard($: Dollar, pin: RepoPin, token: number, wantsAnother: () => void) {
265
366
  const isStale = async () => {
266
367
  if (token !== generation) return true
267
- if (await read($, isDashboard)) return false
368
+ const isOther = !isSamePin(await read($, repoPin), pin)
369
+ const isLeft = !(await read($, isDashboard))
370
+ if (token !== generation) return true
371
+ if (!isOther && !isLeft) return false
268
372
  wantsAnother()
269
373
  return true
270
374
  }
271
375
  const before = await read($, dashboardPane)
376
+ const keptTotal = (await read($, mapPane)).openTotal
272
377
  if (before.fetchedAt === null && !before.isLoading && before.error === null) {
273
378
  await update($, dashboardPane, () => ({ ...before, isLoading: true }))
274
379
  }
275
380
  const [list, index, mapsRead, uatParents] = await Promise.all([
276
- gh($, ['issue', 'list', '--label', 'trailhead:ticket', '--label', 'trailhead:whiteboard', '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS]),
277
- gh($, ['issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', DASHBOARD_FIELDS]),
278
- readMaps($),
279
- readUat($, 'trailhead:whiteboard'),
381
+ readOpen($, pin, 'trailhead:whiteboard', 'trailhead:ticket'),
382
+ gh($, pin, ['issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', DASHBOARD_FIELDS]),
383
+ readMaps($, pin, keptTotal),
384
+ readUat($, pin, 'trailhead:whiteboard'),
280
385
  ])
281
386
  const now = await $.clock.now()
282
387
  if (await isStale()) return
283
388
  const shown = await read($, mapPane)
284
- await setPane($, { ...shown, maps: mapsRead.maps ?? shown.maps, whiteboard: mapsRead.whiteboard })
389
+ await setPane($, { ...shown, maps: mapsRead.maps ?? shown.maps, whiteboard: mapsRead.whiteboard, openTotal: mapsRead.openTotal })
285
390
  await dropClosedView($, mapsRead.maps)
286
391
 
287
392
  const kept = await read($, dashboardPane)
@@ -295,6 +400,33 @@ async function fetchDashboard($: Dollar, token: number, wantsAnother: () => void
295
400
  if (!sameValue(kept, next)) await update($, dashboardPane, () => next)
296
401
  }
297
402
 
403
+ // Checks the repo the session is in and pins it. A move to another GitHub repo empties what the pane
404
+ // holds for the old one (and drops a read of it still in flight), so its tickets never show under the new.
405
+ let paneRepo: string | null = null
406
+ // 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.
409
+ export async function onPinMove($: Dollar, pin: RepoPin | null): Promise<void> {
410
+ generation += 1
411
+ const key = pin === null ? null : pinKey(pin)
412
+ const isOtherRepo = paneRepo !== null && key !== null && key !== paneRepo
413
+ paneRepo = key ?? paneRepo
414
+ if (!isOtherRepo) return
415
+ await update($, mapPane, () => EMPTY)
416
+ await update($, dashboardPane, () => EMPTY_DASHBOARD)
417
+ await update($, activeMap, () => null)
418
+ await update($, viewedMap, () => null)
419
+ await update($, isDashboard, () => false)
420
+ void requestRefresh($, { bypassFloor: true })
421
+ }
422
+
423
+ async function syncPin($: Dollar, init: { isStart?: boolean } = {}): Promise<RepoPin | null> {
424
+ return refreshPin($, { ...init, onMove: pin => onPinMove($, pin) })
425
+ }
426
+
427
+ const safeSyncPin = ($: Dollar, init: { isStart?: boolean } = {}) =>
428
+ syncPin($, init).catch(err => debug($, `trailhead mod: repo pin failed: ${String(err)}`))
429
+
298
430
  async function isPaneUp($: Dollar): Promise<boolean> {
299
431
  return (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
300
432
  }
@@ -305,35 +437,59 @@ function stopTimers() {
305
437
  isFilling = false
306
438
  trailing?.cancel()
307
439
  trailing = null
308
- ticker?.cancel()
440
+ ticker?.timer.cancel()
309
441
  ticker = null
310
442
  }
311
443
 
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.
446
+ const liveOrNull = (t: Tracked | null, now: number): Tracked | null => {
447
+ if (t === null || !isTimerLate(now, t.dueAt, TIMER_GRACE_MS)) return t
448
+ t.timer.cancel()
449
+ return null
450
+ }
451
+
452
+ const startTrailing = ($: Dollar, waitMs: number): Timer =>
453
+ $.clock.after(waitMs, () => {
454
+ trailing = null
455
+ void requestRefresh($)
456
+ })
457
+
458
+ const startTicker = ($: Dollar, now: number): Tracked => ({
459
+ timer: $.clock.every(TIMER_MS, () => void requestRefresh($, { isTick: true })),
460
+ dueAt: now + TIMER_MS,
461
+ })
462
+
312
463
  // `bypassFloor`: the caller's last result was dropped unseen, so reading again is not a repeat.
313
- type Request = { bypassFloor?: boolean }
464
+ // `isWrite`: a tracker write just ran; a read that starts at once is followed by one more at the floor's end,
465
+ // by when the search index (history, the list fallback) has caught up.
466
+ // `isTick`: the slow timer fired, so its next due time moves on.
467
+ type Request = { bypassFloor?: boolean; isWrite?: boolean; isTick?: boolean }
314
468
 
315
469
  // Throttled: inside the floor it keeps ONE trailing refresh for the rest of it, so an event
316
470
  // just after a refresh still shows.
317
471
  async function decide($: Dollar, request: Request) {
318
472
  if (!(await isPaneUp($))) return
319
- ticker ??= $.clock.every(TIMER_MS, () => void requestRefresh($))
473
+ // Nothing pinned: no repo to read, so no call.
474
+ const pin = await syncPin($)
475
+ if (pin === null) return
320
476
  const now = await $.clock.now()
477
+ if (request.isTick === true && ticker !== null) ticker = { ...ticker, dueAt: now + TIMER_MS }
478
+ // A timer the host dropped is started again here, else the pane would wait on it for good.
479
+ ticker = liveOrNull(ticker, now) ?? startTicker($, now)
321
480
  const decision = request.bypassFloor === true ? { run: true as const } : refreshDecision(now, lastStartedAt, MIN_REFRESH_MS)
322
481
  if (!decision.run) {
323
- if (trailing === null) {
324
- trailing = $.clock.after(decision.waitMs, () => {
325
- trailing = null
326
- void requestRefresh($)
327
- })
328
- }
482
+ trailing ??= startTrailing($, decision.waitMs)
329
483
  return
330
484
  }
331
485
  lastStartedAt = now
332
486
  trailing?.cancel()
333
487
  trailing = null
488
+ // 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)
334
490
  const again = () => void requestRefresh($, { bypassFloor: true })
335
- if (await read($, isDashboard)) await fetchDashboard($, generation, again)
336
- else await fetchMap($, generation, again)
491
+ if (await read($, isDashboard)) await fetchDashboard($, pin, generation, again)
492
+ else await fetchMap($, pin, generation, again)
337
493
  }
338
494
 
339
495
  // Does nothing while the pane is closed: the data exists only for the pane.
@@ -362,6 +518,7 @@ async function restoreAfterClear($: Dollar) {
362
518
  const kept = carried
363
519
  carried = null
364
520
  try {
521
+ await syncPin($)
365
522
  if (kept?.isAutoOpened === true) await update($, paneAutoOpened, () => true)
366
523
  if (kept?.isDashboard === true) await update($, isDashboard, () => true)
367
524
  const active = await readActiveMap($)
@@ -373,32 +530,251 @@ async function restoreAfterClear($: Dollar) {
373
530
  void requestRefresh($, { bypassFloor: true })
374
531
  }
375
532
 
376
- // Edits of the tracker through the gh CLI: the narrow set worth a refresh right away.
377
- const GH_ISSUE_WRITE = /\bgh\b[^\n;&|]*\bissue\s+(edit|close|create|comment|reopen)\b/
533
+ // Edits of the tracker from a Bash call, one regex over one shell segment (`[^\n;&|]*`) so a read elsewhere
534
+ // in the line never counts. Three kinds: `gh issue <verb>`; a `gh api` call on a `repos/.../issues` path that
535
+ // writes (an explicit POST/PATCH/PUT/DELETE, or a -f/-F/--field/--raw-field/--input, which makes gh default to
536
+ // POST) and does not say `-X GET`; and the body helper, which rewrites a map's body through the API.
537
+ export const GH_ISSUE_WRITE =
538
+ /\bgh\b[^\n;&|]*\bissue\s+(?:edit|close|create|comment|reopen|delete|transfer)\b|\bgh\b(?=[^\n;&|]*\sapi\s)(?=[^\n;&|]*\brepos\/[^\s;&|]*\/issues\b)(?=[^\n;&|]*(?:\s(?:-X|--method)[\s=]*["']?(?:POST|PATCH|PUT|DELETE)\b|\s(?:-[fF]|--field\b|--raw-field\b|--input\b)))(?![^\n;&|]*\s(?:-X|--method)[\s=]*["']?GET\b)|\btrailhead-issue-body\.js\b/
539
+
540
+ // The host runs the verb in the session's own directory, which a shell `cd` may have moved, and the mod cannot
541
+ // move it back: `out` hands the pinned checkout to the verb, which moves first (#312). A checkout that cannot
542
+ // be read is `unknown`, refused, but not told it is elsewhere.
543
+ async function whereIsSession($: Dollar, pin: RepoPin): Promise<'in' | 'out' | 'unknown'> {
544
+ try {
545
+ const { repo, toplevel } = await currentRepo($)
546
+ return isPinCheckout(pin, repo, toplevel ?? (await toplevelOf($))) ? 'in' : 'out'
547
+ } catch {
548
+ return 'unknown'
549
+ }
550
+ }
551
+
552
+ // Where the press stands, read at the press and again at the send: the pin to hand over, or the refusal.
553
+ async function placeOf($: Dollar): Promise<{ pin: RepoPin; where: 'in' | 'out' } | { refusal: string }> {
554
+ const pin = await read($, repoPin)
555
+ if (pin === null) return { refusal: 'no repo pinned' }
556
+ const where = await whereIsSession($, pin)
557
+ if (where === 'unknown') return { refusal: 'checkout unreadable' }
558
+ // Out of it, the verb must move into the pin: a removed worktree or clone would fail only after the destructive /clear.
559
+ if (where === 'out' && !(await isCheckoutRoot($, pin.root))) return { refusal: 'checkout unreadable' }
560
+ return { pin, where }
561
+ }
562
+
563
+ // Whether the pinned root still answers as a checkout root, the same test the verb's helper applies.
564
+ async function isCheckoutRoot($: Dollar, root: string): Promise<boolean> {
565
+ try {
566
+ const out = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: root, timeoutMs: 5_000 })
567
+ return out.exitCode === 0 && out.stdout.trim() === root
568
+ } catch {
569
+ return false
570
+ }
571
+ }
378
572
 
379
- // Clears the conversation, then runs `/trailhead:work <n>` in the new one; the host queues both
380
- // behind a running turn. The claim is taken with the version it read, so two presses never both
381
- // pass, and `workInFlight` holds the next ones off while the clear has emptied the atom.
382
- // Cleared once the calls settle.
383
- async function startWork($: Dollar, number: number) {
384
- if (workInFlight !== null) return
573
+ // A press during a running turn is held (row `queued`, with a cancel button) and sent when the main turn ends;
574
+ // with no turn running it is sent at once. With no pin or an unreadable checkout it refuses before anything, as the
575
+ // clear is destructive; outside the pinned repo it goes through and the verb moves into it. The pendingWork claim is
576
+ // taken with the version it read, so two presses never both pass.
577
+ async function startVerb($: Dollar, verb: 'work' | 'resume', number: number) {
578
+ if (held !== null || inFlight !== null) return
579
+ const place = await placeOf($)
580
+ if ('refusal' in place) return refuse($, number, place.refusal)
385
581
  const { value, version } = await $.state.get(PENDING)
386
582
  if (value != null) return
583
+ if (held !== null || inFlight !== null) return
387
584
  if (!(await $.state.set(PENDING, number, { ifVersion: version })).isSet) return
388
- workInFlight = number
585
+ const press: HeldPress = { id: (pressCount += 1), verb, number }
586
+ claiming = { number, isCancelled: false }
587
+ try {
588
+ // A press that needs no hold is never shown queued: no turn running, no cancel button.
589
+ if (isMainTurnRunning()) {
590
+ await update($, pendingPhase, () => 'queued')
591
+ // A cancel landed during that write: nothing was sent, so dropping the press is the whole cancel.
592
+ if (claiming.isCancelled) return dropPending($)
593
+ // The turn mark is synchronous with the turn start, so this read is the latest word on it.
594
+ if (isMainTurnRunning()) {
595
+ held = press
596
+ return
597
+ }
598
+ }
599
+ } catch (err) {
600
+ debug($, `trailhead mod: ${verb} ${number} did not start: ${String(err)}`)
601
+ return dropPending($)
602
+ } finally {
603
+ claiming = null
604
+ }
605
+ await dispatch($, press)
606
+ }
607
+
608
+ // The toast box is narrow and already names the plugin: the text stays a few words.
609
+ const refuse = ($: Dollar, number: number, refusal: string) => $.ui.toast(`#${number} not started: ${refusal}`)
610
+
611
+ // The pending marks go phase first, the claim last: until pendingWork is clear no new press can take the slot,
612
+ // so a new press never has its phase erased by the old one's cleanup.
613
+ // `dropping` counts the drops in flight, so the stale-claim sweep (clearStale) never starts beside one.
614
+ let dropping = 0
615
+ async function dropPending($: Dollar) {
616
+ dropping += 1
617
+ try {
618
+ try {
619
+ await update($, pendingPhase, () => null)
620
+ } finally {
621
+ await update($, pendingWork, () => null).catch(err => debug($, `trailhead mod: pending mark not cleared: ${String(err)}`))
622
+ }
623
+ } finally {
624
+ dropping -= 1
625
+ }
626
+ }
627
+
628
+ // Sends the press: /clear, then `/trailhead:<verb> <n>` in the new conversation. Every await comes first, then the
629
+ // turn check and the /clear call follow with no await between, so a turn that started at any point before is seen
630
+ // and the press is held again instead of queued behind it in the host, where it could not be cancelled.
631
+ async function dispatch($: Dollar, press: HeldPress) {
632
+ inFlight = press.id
633
+ dispatching = { id: press.id, number: press.number, isCancelled: false, isSent: false }
634
+ let isReheld = false
389
635
  try {
390
- const before = await $.session.id()
391
- await $.command.run({ command: CLEAR_COMMAND, args: '' })
636
+ let clearing: Promise<unknown> | null = null
637
+ let before = ''
638
+ let args = String(press.number)
639
+ while (clearing === null) {
640
+ await update($, pendingPhase, () => 'starting')
641
+ before = await $.session.id()
642
+ // Read at the send, not only at the press: a held press may find the session moved or the checkout gone.
643
+ const place = await placeOf($)
644
+ if ('refusal' in place) return refuse($, press.number, place.refusal)
645
+ args = place.where === 'out' ? `${press.number} ${checkoutArg(place.pin)}` : String(press.number)
646
+ // A cancel pressed while the row still read queued: dropped here, the last line before the clear.
647
+ if (dispatching?.isCancelled === true) return debug($, `trailhead mod: ${press.verb} ${press.number} cancelled before the clear`)
648
+ if (!isMainTurnRunning()) {
649
+ clearing = $.command.run({ command: CLEAR_COMMAND, args: '' })
650
+ dispatching = { ...dispatching!, isSent: true }
651
+ break
652
+ }
653
+ // A turn began meanwhile: back to queued. Ownership returns to `held` only while the turn still runs, else go round.
654
+ await update($, pendingPhase, () => 'queued')
655
+ // A cancel landed during that write: drop the press (isReheld stays false, so the finally clears the marks).
656
+ if (dispatching?.isCancelled === true) return debug($, `trailhead mod: ${press.verb} ${press.number} cancelled before the clear`)
657
+ if (isMainTurnRunning()) {
658
+ held = press
659
+ isReheld = true
660
+ return
661
+ }
662
+ }
663
+ await clearing
392
664
  // A clear another hook answered without running resolves too: the verb must not start in the old conversation.
393
- if ((await $.session.id()) === before) return debug($, `trailhead mod: work ${number} skipped, the conversation was not cleared`)
394
- // The clear emptied the atom: the row is still queued in the new session.
395
- await update($, pendingWork, () => number)
396
- await $.command.run({ command: WORK_COMMAND, args: String(number) })
665
+ if ((await $.session.id()) === before) return debug($, `trailhead mod: ${press.verb} ${press.number} skipped, the conversation was not cleared`)
666
+ // The clear emptied the atoms: the row is still starting in the new session.
667
+ await update($, pendingWork, () => press.number)
668
+ await update($, pendingPhase, () => 'starting')
669
+ await $.command.run({ command: press.verb === 'work' ? WORK_COMMAND : RESUME_COMMAND, args })
670
+ } catch (err) {
671
+ debug($, `trailhead mod: ${press.verb} ${press.number} did not start: ${String(err)}`)
672
+ } finally {
673
+ if (dispatching?.id === press.id) dispatching = null
674
+ // A press held again keeps its marks and its place in `held`; only the in-flight guard goes.
675
+ if (isReheld) inFlight = null
676
+ else {
677
+ try {
678
+ await dropPending($)
679
+ } finally {
680
+ if (inFlight === press.id) inFlight = null
681
+ }
682
+ }
683
+ }
684
+ }
685
+
686
+ // The zero-delay timer after a main turn ends. It claims the held press in its first statements, with no await,
687
+ // so the press stays cancellable until this instant; a turn running again (a queued prompt) leaves it held.
688
+ function releaseHeld($: Dollar) {
689
+ if (isMainTurnRunning()) return
690
+ const press = held
691
+ if (press === null) return
692
+ held = null
693
+ void dispatch($, press).catch(err => debug($, `trailhead mod: held press failed: ${String(err)}`))
694
+ }
695
+
696
+ // The cancel button of a queued row: a held press never leaves the panel, so nothing is sent and the conversation
697
+ // stays. A press released but not yet at its /clear is marked and dropped by the dispatch at that line. A queued
698
+ // row with no press behind it (a hot reload reset the module, the atoms stayed) is cleared. Else nothing to cancel.
699
+ async function cancelQueued($: Dollar, number: number) {
700
+ if (held?.number === number) {
701
+ held = null
702
+ return dropPending($)
703
+ }
704
+ if (dispatching?.number === number) {
705
+ if (!dispatching.isSent) dispatching = { ...dispatching, isCancelled: true }
706
+ return
707
+ }
708
+ if (claiming?.number === number) {
709
+ claiming = { ...claiming, isCancelled: true }
710
+ return
711
+ }
712
+ if (claiming !== null || dispatching !== null || inFlight !== null || held !== null) return
713
+ if ((await read($, pendingPhase)) !== 'queued' || (await read($, pendingWork)) !== number) return
714
+ // The reads above awaited: a press made meanwhile owns the atoms.
715
+ if (claiming !== null || dispatching !== null || inFlight !== null || held !== null) return
716
+ await dropPending($)
717
+ }
718
+
719
+ // A pending claim the pane draws with no press behind it and no `queued` mark (a hot reload reset the module,
720
+ // the atoms stayed) would block every press for good. Run from a zero-delay timer, as a render cannot write state.
721
+ // The claim is read first, then the module state is checked: a press that took the claim has set its guards by
722
+ // the time that read answers.
723
+ let isClearingStale = false
724
+ async function clearStale($: Dollar) {
725
+ if (isClearingStale) return
726
+ isClearingStale = true
727
+ try {
728
+ const isIdle = () => held === null && claiming === null && dispatching === null && inFlight === null && dropping === 0
729
+ if (!isIdle()) return
730
+ // The claim is read with its version and released only if that version still stands, so a press that took the
731
+ // slot meanwhile is never cleared. The phase goes first (while the stale claim still blocks every new press),
732
+ // then the compare-and-set on the claim.
733
+ const { value, version } = await $.state.get(PENDING)
734
+ if (value == null || !isIdle()) return
735
+ if ((await read($, pendingPhase)) === 'queued' || !isIdle()) return
736
+ debug($, 'trailhead mod: cleared a pending press left with no owner')
737
+ dropping += 1
738
+ try {
739
+ await update($, pendingPhase, () => null)
740
+ await $.state.set(PENDING, null, { ifVersion: version })
741
+ } finally {
742
+ dropping -= 1
743
+ }
397
744
  } catch (err) {
398
- debug($, `trailhead mod: work ${number} did not start: ${String(err)}`)
745
+ debug($, `trailhead mod: stale press not cleared: ${String(err)}`)
399
746
  } finally {
400
- workInFlight = null
401
- await update($, pendingWork, () => null)
747
+ isClearingStale = false
748
+ }
749
+ }
750
+
751
+ // The whiteboard ticket the panel last switched to. A module variable, not an atom: a /clear empties the atoms,
752
+ // and a ticket the person already left for the map must not pull the panel back.
753
+ let followedTicket: number | null = null
754
+
755
+ // Called by the work state's refresh once it has applied (the state atom is written there): when this session's
756
+ // own whiteboard ticket is in play the panel shows the whiteboard, once per ticket (#306).
757
+ export async function followWhiteboard($: Dollar): Promise<void> {
758
+ try {
759
+ const { follow, memo } = nextFollow(followedTicket, await read($, workState), await read($, ownTicket), await read($, isOrphaned))
760
+ followedTicket = memo
761
+ if (!follow) return
762
+ await update($, isDashboard, () => true)
763
+ void requestRefresh($, { bypassFloor: true })
764
+ } catch (err) {
765
+ debug($, `trailhead mod: whiteboard follow failed: ${String(err)}`)
766
+ }
767
+ }
768
+
769
+ // A whiteboard ticket created while a map is shown: a hint, never a switch.
770
+ async function hintWhiteboard($: Dollar, command: unknown, call: unknown): Promise<void> {
771
+ try {
772
+ if (typeof command !== 'string' || !isWhiteboardCreate(command)) return
773
+ const number = createdIssueNumber(call)
774
+ if (number === null || (await read($, isDashboard))) return
775
+ $.ui.toast(whiteboardHint(number))
776
+ } catch (err) {
777
+ debug($, `trailhead mod: whiteboard hint failed: ${String(err)}`)
402
778
  }
403
779
  }
404
780
 
@@ -454,8 +830,16 @@ async function autoOpen($: Dollar) {
454
830
  }
455
831
  }
456
832
 
833
+ // Un pannello aperto ma non disegnato (l'auto-open sotto la soglia dei pannelli non richiesti, tipico di
834
+ // 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.
457
836
  async function togglePanel($: Dollar): Promise<string> {
458
- if ((await $.ui.panes()).some(pane => pane.id === PANE_ID)) {
837
+ const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
838
+ if (pane?.isPlaced === false) {
839
+ const reopened = await openPane($)
840
+ if (reopened.isPlaced) return 'Map panel opened.'
841
+ }
842
+ if (pane !== undefined) {
459
843
  await $.ui.close({ id: PANE_ID })
460
844
  return 'Map panel closed.'
461
845
  }
@@ -482,6 +866,7 @@ export function registerMapPane(on: On) {
482
866
  // when neither has one, and register.tsx already hooks these events bare.
483
867
  on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
484
868
  const result = await next(e)
869
+ await safeSyncPin($, { isStart: true })
485
870
  await safeReadActiveMap($)
486
871
  void requestRefresh($)
487
872
  return result
@@ -489,14 +874,23 @@ export function registerMapPane(on: On) {
489
874
 
490
875
  on('turn.complete', { reason: ['answer', 'aborted', 'refusal', 'error'] }, async ($, e, next) => {
491
876
  const result = await next(e)
877
+ await safeSyncPin($)
492
878
  await safeReadActiveMap($)
493
879
  void requestRefresh($)
880
+ // The band's hook marks the turn over; the timer runs after the whole chain, and releases a held press.
881
+ if (e.agentId === undefined) $.clock.after(0, () => releaseHeld($))
494
882
  return result
495
883
  })
496
884
 
497
- // A /clear empties the atoms: note what the pane must get back, then restore it in the new session.
498
- on('session.end', { reason: 'clear' }, async ($, e, next) => {
499
- await carryOverClear($).catch(err => debug($, `trailhead mod: map pane carry-over failed: ${String(err)}`))
885
+ // Every reason: a /clear empties the atoms, so note what the pane must get back and restore it in the new
886
+ // session; any ending drops a held press (nothing is left to run it in). The mod's own /clear never finds one:
887
+ // only a dispatch issues it, and a held press and a dispatch never coexist.
888
+ on('session.end', { reason: ['clear', 'resume', 'logout', 'prompt_input_exit', 'other'] }, async ($, e, next) => {
889
+ if (e.reason === 'clear') await carryOverClear($).catch(err => debug($, `trailhead mod: map pane carry-over failed: ${String(err)}`))
890
+ if (held !== null) {
891
+ held = null
892
+ await dropPending($).catch(err => debug($, `trailhead mod: held press not dropped: ${String(err)}`))
893
+ }
500
894
  return next(e)
501
895
  })
502
896
 
@@ -509,7 +903,8 @@ export function registerMapPane(on: On) {
509
903
  // After the call ran: a Bash `gh issue` write changed the tracker.
510
904
  on('tool.call', { tool: 'Bash', command: GH_ISSUE_WRITE }, async ($, e, next) => {
511
905
  const result = await next(e)
512
- void requestRefresh($)
906
+ void requestRefresh($, { isWrite: true })
907
+ await hintWhiteboard($, e.command, result)
513
908
  return result
514
909
  // This hook never judges the call: if it fails, the call goes on untouched.
515
910
  }).catch((_$, e, next) => next(e))
@@ -546,7 +941,10 @@ export function registerMapPane(on: On) {
546
941
  const play = await read($, workState)
547
942
  const orphaned = await read($, isOrphaned)
548
943
  const own = await read($, ownTicket)
944
+ const pin = await read($, repoPin)
549
945
  const pending = await read($, pendingWork)
946
+ const phase = await read($, pendingPhase)
947
+ if (pending !== null && phase !== 'queued') $.clock.after(0, () => void clearStale($))
550
948
  const now = await $.clock.now()
551
949
  const cols = e.props.bodyColumns
552
950
  const isDock = e.props.placement === 'dock'
@@ -615,10 +1013,14 @@ export function registerMapPane(on: On) {
615
1013
  </Box>
616
1014
  )
617
1015
 
1016
+ // The total of open tickets across every open map and the whiteboard, one dim line; hidden while unknown.
1017
+ const totalText = openTotalLabel(data.openTotal?.total ?? null, data.openTotal?.isMapsCapped ?? false, cols)
1018
+ const totalLine = totalText === null ? null : text('open-total', dim, totalText)
1019
+
618
1020
  // This session's own ticket survives a work-state file another session took over; without one, the file decides.
619
1021
  const isInPlay = (t: PaneTicket) => (own !== null ? t.number === own : play !== null && !orphaned && play.ticket === t.number)
620
1022
  const row = (t: PaneTicket, hasMarkColumn: boolean) => {
621
- const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, columns: cols })
1023
+ const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, pendingPhase: phase, columns: cols })
622
1024
  const style = r.isInPlay ? accent : r.isDim ? dim : {}
623
1025
  const holder = r.isDim ? dim : {}
624
1026
  const line = (
@@ -633,12 +1035,25 @@ export function registerMapPane(on: On) {
633
1035
  {r.aside?.kind === 'holder' ? text(`holder-${t.number}`, holder, r.aside.text) : null}
634
1036
  {r.aside?.kind === 'self' ? text(`self-${t.number}`, { color: ACCENT }, r.aside.text) : null}
635
1037
  {r.aside?.kind === 'queued' ? text(`queued-${t.number}`, { color: AMBER }, r.aside.text) : null}
636
- {r.aside?.kind === 'work' ? (
1038
+ {r.aside?.kind === 'starting' ? text(`starting-${t.number}`, { color: AMBER }, r.aside.text) : null}
1039
+ {r.aside?.kind === 'queued' ? (
1040
+ <Button
1041
+ key={`cancel-${t.number}`}
1042
+ label={CANCEL_LABEL}
1043
+ onPress={() => {
1044
+ void cancelQueued($, t.number).catch(err => debug($, `trailhead mod: cancel press failed: ${String(err)}`))
1045
+ }}
1046
+ />
1047
+ ) : null}
1048
+ {r.aside?.kind === 'work' || r.aside?.kind === 'resume' ? (
637
1049
  <Button
638
- key={`work-${t.number}`}
639
- label="work"
1050
+ key={`${r.aside.kind}-${t.number}`}
1051
+ label={r.aside.kind}
640
1052
  variant="primary"
641
- onPress={() => void startWork($, t.number).catch(err => debug($, `trailhead mod: work press failed: ${String(err)}`))}
1053
+ onPress={() => {
1054
+ const verb = r.aside?.kind === 'resume' ? 'resume' : 'work'
1055
+ void startVerb($, verb, t.number).catch(err => debug($, `trailhead mod: ${verb} press failed: ${String(err)}`))
1056
+ }}
642
1057
  />
643
1058
  ) : null}
644
1059
  </Box>
@@ -671,6 +1086,7 @@ export function registerMapPane(on: On) {
671
1086
  const ticketGroups = (tickets: readonly PaneTicket[]) => {
672
1087
  const groups = groupTickets(tickets)
673
1088
  return [
1089
+ group('needs-you', 'Needs you', groups.needsYou),
674
1090
  group('frontier', 'Frontier', groups.frontier),
675
1091
  group('in-progress', 'In progress', groups.inProgress),
676
1092
  group('uat', 'UAT', groups.uat),
@@ -728,9 +1144,23 @@ export function registerMapPane(on: On) {
728
1144
  })
729
1145
  }
730
1146
 
1147
+ // A list older than one slow-timer period missed its reads (the pane undrawn when they came, or the
1148
+ // timer dropped): the render asks for one, as for an empty view. A failing read keeps its slow pace.
1149
+ 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)
1151
+
1152
+ // No GitHub repo was ever pinned: say so rather than a `gh` failure; nothing is read.
1153
+ if (pin === null) {
1154
+ return (
1155
+ <Box flexDirection="column" width={cols}>
1156
+ {text('no-repo', dim, NO_REPO)}
1157
+ </Box>
1158
+ )
1159
+ }
1160
+
731
1161
  if (isBoard) {
732
1162
  const isLoaded = board.fetchedAt !== null
733
- if (!isLoaded && !board.isLoading && board.error === null) askFill()
1163
+ if ((!isLoaded && !board.isLoading && board.error === null) || isOverdue(board)) askFill()
734
1164
  const heading = fit('Dashboard', headingRoom(cols, MAP_LABEL))
735
1165
  const title = (
736
1166
  <Box key="dashboard-title">
@@ -741,6 +1171,7 @@ export function registerMapPane(on: On) {
741
1171
  return (
742
1172
  <Box flexDirection="column" width={cols} {...(isDock ? { minHeight: e.props.scroll.bodyRows } : {})}>
743
1173
  {headerRow(title)}
1174
+ {totalLine}
744
1175
  {mapsGroup}
745
1176
  <Box key="whiteboard" marginTop={gap}>
746
1177
  {text('head-whiteboard', bold, whiteboardHeading(isLoaded ? board.tickets : null, board.isCapped))}
@@ -760,13 +1191,14 @@ export function registerMapPane(on: On) {
760
1191
  <Box flexDirection="column" width={cols}>
761
1192
  {text('empty', dim, 'No active map. Run /trailhead:map to pick one.')}
762
1193
  {whiteboardRow}
1194
+ {totalLine}
763
1195
  {mapsGroup}
764
1196
  </Box>
765
1197
  )
766
1198
  }
767
1199
  const isCurrent = data.map?.number === target
768
1200
  const isLoading = !isCurrent && (data.isLoading || data.error === null)
769
- if (!isCurrent && !data.isLoading && data.error === null) askFill()
1201
+ if ((!isCurrent && !data.isLoading && data.error === null) || (isCurrent && isOverdue(data))) askFill()
770
1202
 
771
1203
  const map = isCurrent ? data.map : null
772
1204
  const heading = map === null ? `map #${target}` : fit(`#${map.number} ${map.title}`, cols)
@@ -785,6 +1217,7 @@ export function registerMapPane(on: On) {
785
1217
  {viewing}
786
1218
  {text('loading', dim, 'Loading tickets…')}
787
1219
  {whiteboardRow}
1220
+ {totalLine}
788
1221
  {mapsGroup}
789
1222
  </Box>
790
1223
  )
@@ -815,6 +1248,7 @@ export function registerMapPane(on: On) {
815
1248
  {header}
816
1249
  {viewing}
817
1250
  {whiteboardRow}
1251
+ {totalLine}
818
1252
  {mapsGroup}
819
1253
  {ticketGroups(tickets)}
820
1254
  {isCurrent && data.error === null && tickets.length === 0 ? (