@erclx/canon 5.77.0 → 5.79.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 (125) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +12 -0
  4. package/claude/skills/app-drive/SKILL.md +44 -8
  5. package/claude/skills/app-drive/references/config.md +18 -8
  6. package/claude/skills/app-drive/references/drive-record.md +4 -0
  7. package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/auto-ship/SKILL.md +5 -4
  9. package/claude/skills/auto-ship/references/draft-mark.md +33 -15
  10. package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
  11. package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
  12. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  13. package/claude/skills/code-craft/SKILL.md +7 -0
  14. package/claude/skills/code-craft/references/adopted.md +8 -0
  15. package/claude/skills/git-followup/SKILL.md +1 -0
  16. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  17. package/claude/skills/git-pr/SKILL.md +2 -0
  18. package/claude/skills/git-pr/references/final-command.md +34 -2
  19. package/claude/skills/git-pr/references/pr.md +1 -1
  20. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  21. package/claude/skills/git-ship/SKILL.md +16 -11
  22. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  23. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  24. package/claude/skills/plan-feature/references/plan.md +2 -0
  25. package/claude/skills/pr-evidence/SKILL.md +6 -1
  26. package/claude/skills/pr-evidence/references/evidence.md +1 -0
  27. package/claude/skills/pr-evidence/references/reel.md +9 -1
  28. package/claude/skills/review-address/SKILL.md +2 -1
  29. package/claude/skills/review-craft/references/ui.md +13 -1
  30. package/claude/skills/review-pr/references/body-variants.md +2 -0
  31. package/claude/skills/review-pr/references/close-out.md +2 -0
  32. package/claude/skills/review-pr/references/key-changes.md +2 -1
  33. package/claude/skills/review-pr/references/post.md +1 -0
  34. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  35. package/claude/skills/review-ui/SKILL.md +2 -2
  36. package/claude/skills/review-ui/references/driving.md +32 -4
  37. package/claude/skills/review-ui/references/post.md +6 -2
  38. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  39. package/claude/skills/role-orchestrator/SKILL.md +4 -4
  40. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  41. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  42. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  43. package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
  44. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
  45. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
  46. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  47. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  48. package/claude/skills/role-worker/SKILL.md +2 -1
  49. package/claude/skills/task-board/SKILL.md +1 -0
  50. package/claude/skills/task-board/references/tasks.md +1 -0
  51. package/claude/skills/test-craft/SKILL.md +1 -0
  52. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  53. package/claude/skills/ui-checklist/SKILL.md +2 -1
  54. package/claude/skills/ui-craft/references/capture.md +16 -0
  55. package/claude/skills/ui-film/REQUIREMENT.md +2 -2
  56. package/claude/skills/ui-film/SKILL.md +6 -1
  57. package/claude/skills/ui-film/references/compose-craft.md +68 -11
  58. package/claude/skills/ui-film/references/config.md +2 -2
  59. package/claude/skills/ui-film/references/handoff.md +1 -1
  60. package/claude/skills/ui-film/references/harness.md +71 -0
  61. package/claude/skills/ui-film/references/take-craft.md +42 -17
  62. package/docs/workflow/ai-workflow.md +1 -1
  63. package/docs/workflow/operating-model.md +6 -5
  64. package/docs/workflow/parallel-features.md +2 -1
  65. package/docs/workflow/visual-design-workflow.md +2 -1
  66. package/governance/ledger/skills/code-craft/SKILL.md +9 -0
  67. package/governance/ledger/skills/test-craft/SKILL.md +1 -0
  68. package/governance/ledger/skills/ui-craft/capture.md +18 -5
  69. package/package.json +4 -3
  70. package/src/commands/pr.ts +255 -46
  71. package/src/commands/sessions.ts +88 -6
  72. package/src/commands/take.ts +12 -1
  73. package/src/commands/tasks.ts +41 -2
  74. package/src/design/components.ts +5 -107
  75. package/src/design/favicon.ts +21 -17
  76. package/src/design/render.ts +9 -27
  77. package/src/design/template.ts +42 -0
  78. package/src/design/templates/favicon.svg +17 -0
  79. package/src/design/templates/hand-drawn-figure.css +54 -0
  80. package/src/design/templates/preview.html +27 -0
  81. package/src/design/templates/scrollbar.css +29 -0
  82. package/src/design/templates/status.css +24 -0
  83. package/src/gate/embedded.ts +400 -0
  84. package/src/gate/stages.ts +8 -0
  85. package/src/git/files.ts +5 -2
  86. package/src/indexes/frontmatter.ts +27 -6
  87. package/src/indexes/render.ts +21 -3
  88. package/src/markdown/colons.ts +14 -4
  89. package/src/markdown/heading-case.ts +9 -2
  90. package/src/pr/bijection.ts +8 -0
  91. package/src/pr/evidence.ts +81 -13
  92. package/src/pr/frames.ts +1 -6
  93. package/src/pr/health.ts +63 -15
  94. package/src/pr/lift.ts +77 -9
  95. package/src/pr/links.ts +160 -0
  96. package/src/pr/local.ts +17 -4
  97. package/src/pr/paths.ts +20 -2
  98. package/src/pr/rest.ts +2 -0
  99. package/src/pr/review-scope.ts +28 -0
  100. package/src/pr/review-state.ts +5 -5
  101. package/src/pr/waiver.ts +45 -0
  102. package/src/sessions/stop.ts +145 -0
  103. package/src/sessions/transfer/bundle.ts +1 -1
  104. package/src/take/camera.ts +337 -208
  105. package/src/take/encode.ts +16 -42
  106. package/src/take/film.ts +7 -1
  107. package/src/take/frame-clock.ts +46 -0
  108. package/src/take/log.ts +43 -2
  109. package/src/take/perform.ts +85 -13
  110. package/src/take/plan.ts +26 -5
  111. package/src/take/reel.ts +30 -5
  112. package/src/take/screencast.ts +223 -13
  113. package/src/take/stepper.ts +24 -4
  114. package/src/tasks/holders.ts +198 -0
  115. package/src/tasks/validate.ts +148 -0
  116. package/src/commands/fixtures/gh.sh +0 -64
  117. package/src/take/fixtures/busy.html +0 -107
  118. package/src/take/fixtures/fade.html +0 -49
  119. package/src/take/fixtures/fetch.html +0 -38
  120. package/src/take/fixtures/input.html +0 -138
  121. package/src/take/zoom/NOTICE.md +0 -45
  122. package/src/take/zoom/math-utils.ts +0 -69
  123. package/src/take/zoom/region-utils.ts +0 -55
  124. package/src/take/zoom/spring.ts +0 -133
  125. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
@@ -1,8 +1,8 @@
1
- import { mkdtempSync, rmSync } from 'node:fs'
2
- import { tmpdir } from 'node:os'
3
- import { join } from 'node:path'
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
4
3
  import { chromium } from 'playwright-core'
5
- import { lastFrame, probeFrames, transcode } from '@/take/encode'
4
+ import { lastFrame, openFrameEncoder, probeFrames } from '@/take/encode'
5
+ import { frameStamp, slotFrames } from '@/take/frame-clock'
6
6
  import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
7
7
  import {
8
8
  type PointerInput,
@@ -11,7 +11,7 @@ import {
11
11
  performSteps,
12
12
  } from '@/take/perform'
13
13
  import type { Beat } from '@/take/plan'
14
- import { HOLD_MS } from '@/take/stepper'
14
+ import { HOLD_MS, withinCap } from '@/take/stepper'
15
15
 
16
16
  /**
17
17
  * Films a beat in real time through Playwright's screencast, for a page frame
@@ -27,9 +27,21 @@ const SETTLE_MS = 2500
27
27
  /** How long a refusal waits on the screenshot it shows. */
28
28
  const STILL_CAP_MS = 2000
29
29
 
30
+ /**
31
+ * How long a move waits on the page's next animation frame before it goes
32
+ * anyway, since a page whose frames stall is the stepper's case and this route
33
+ * must still film.
34
+ */
35
+ const ALIGN_CAP_MS = 100
36
+
37
+ /** How long the calibration waits on the page to paint its move. */
38
+ const CALIBRATE_CAP_MS = 500
39
+
30
40
  export interface ScreencastTake {
31
41
  readonly frames: number
32
42
  readonly events: readonly LogEvent[]
43
+ /** What `events` were shifted by, absent when the page gave no frame to read. */
44
+ readonly lag?: number
33
45
  /** The clip's closing frame as a JPEG. */
34
46
  readonly still: Buffer
35
47
  }
@@ -39,8 +51,13 @@ export async function filmScreencast(request: {
39
51
  readonly beat: Beat
40
52
  readonly clip: string
41
53
  }): Promise<ScreencastTake> {
42
- const scratch = mkdtempSync(join(tmpdir(), 'canon-take-cast-'))
43
- const browser = await chromium.launch()
54
+ // Ahead of the launch, so a folder that refuses leaves no browser holding the
55
+ // process open past its refusal.
56
+ const scratch = openSpool(request.clip)
57
+ const browser = await chromium.launch().catch((error: unknown) => {
58
+ scratch.remove()
59
+ throw error
60
+ })
44
61
  let still: (() => Promise<Buffer | undefined>) | undefined
45
62
  try {
46
63
  const page = await browser.newPage({ viewport: VIEWPORT })
@@ -56,7 +73,11 @@ export async function filmScreencast(request: {
56
73
  const stageOn = (clock: () => number): Stage => ({
57
74
  now: clock,
58
75
  hold: (ms) => page.waitForTimeout(ms),
76
+ align: () => nextFrame(page, ALIGN_CAP_MS).then(() => undefined),
59
77
  pointer: async (input) => {
78
+ // A move waited on its frame already, so padding it would land the
79
+ // next one a frame late.
80
+ if (input.type === 'move') return send(page.mouse, input)
60
81
  const sent = clock()
61
82
  await send(page.mouse, input)
62
83
  const spent = clock() - sent
@@ -79,11 +100,35 @@ export async function filmScreencast(request: {
79
100
  const live = stageOn(() => performance.now())
80
101
  await performSteps(live, request.beat.setup, { pointer: START })
81
102
  if (request.beat.setup.length > 0) await live.hold(1000)
82
- await page.mouse.move(START.x, START.y)
103
+ const lag = await calibrate(page)
83
104
 
84
- const webm = join(scratch, 'take.webm')
85
105
  const events: LogEvent[] = []
86
- await page.screencast.start({ path: webm, size: VIEWPORT, fps: FPS })
106
+ const spool: SpooledFrame[] = []
107
+ let spoolFailure: unknown
108
+ await page.screencast.start({
109
+ size: VIEWPORT,
110
+ // The docs tie this to `path`, but 1.64.1 still reads it here, where it
111
+ // doubles the frames Chromium may hold unacknowledged.
112
+ fps: FPS,
113
+ // Spooled rather than held, since a busy page sends about 19 MB a second.
114
+ // The write stays synchronous, so the acknowledgement Chromium paces its
115
+ // frames on waits for no encoder. Playwright calls this from a listener
116
+ // nothing awaits, so a throw here would surface as an unhandled rejection.
117
+ onFrame: ({ data, timestamp }) => {
118
+ const stamp = frameStamp({
119
+ presentedWall: timestamp,
120
+ wallNow: Date.now(),
121
+ monoNow: performance.now(),
122
+ })
123
+ const path = join(scratch.folder, `${spool.length}.jpg`)
124
+ try {
125
+ writeFileSync(path, data)
126
+ spool.push({ path, stamp })
127
+ } catch (error) {
128
+ spoolFailure ??= error
129
+ }
130
+ },
131
+ })
87
132
  const started = performance.now()
88
133
  const recording = stageOn(() => performance.now() - started)
89
134
  await recording.hold(HOLD_MS)
@@ -94,21 +139,186 @@ export async function filmScreencast(request: {
94
139
  await recording.hold(HOLD_MS)
95
140
  const filmedMs = recording.now()
96
141
  await page.screencast.stop()
142
+ if (spoolFailure !== undefined) throw spoolFailure
97
143
 
98
- await transcode(webm, request.clip, FPS, filmedMs)
144
+ await encodeSpool(spool, request.clip, started, filmedMs)
99
145
  const frames = await probeFrames(request.clip)
100
- return { frames, events, still: await lastFrame(request.clip, frames) }
146
+ const closing = await lastFrame(request.clip, frames)
147
+ if (lag === undefined) return { frames, events, still: closing }
148
+ return {
149
+ frames,
150
+ events: events.map((event) => shifted(event, lag)),
151
+ lag: Number(lag.toFixed(1)),
152
+ still: closing,
153
+ }
101
154
  } catch (error) {
102
155
  if (error instanceof TargetRefused && still) error.still = await still()
103
156
  throw error
104
157
  } finally {
105
158
  await browser.close()
106
- rmSync(scratch, { recursive: true, force: true })
159
+ scratch.remove()
107
160
  }
108
161
  }
109
162
 
163
+ type Page = import('playwright-core').Page
110
164
  type Mouse = import('playwright-core').Mouse
111
165
 
166
+ interface SpooledFrame {
167
+ readonly path: string
168
+ /** When the frame was presented, on the monotonic clock. */
169
+ readonly stamp: number
170
+ }
171
+
172
+ /**
173
+ * Opens the folder holding one take's frames, beside its clip rather than in a
174
+ * temp folder nothing sweeps, since a 34 s beat on a busy page spools about
175
+ * 650 MB. A take killed outright runs no handler, so it leaves the folder where
176
+ * its owner looks, and the next take of that beat clears it.
177
+ */
178
+ function openSpool(clip: string): { folder: string; remove: () => void } {
179
+ const folder = join(dirname(clip), 'spool')
180
+ rmSync(folder, { recursive: true, force: true })
181
+ mkdirSync(folder, { recursive: true })
182
+ const remove = (): void => {
183
+ process.off('exit', remove)
184
+ process.off('SIGINT', interrupt)
185
+ process.off('SIGTERM', interrupt)
186
+ rmSync(folder, { recursive: true, force: true })
187
+ }
188
+ // A signal skips the exit event, so it removes the folder and then re-raises
189
+ // itself with this handler gone, ending the process as it would have.
190
+ const interrupt = (signal: NodeJS.Signals): void => {
191
+ remove()
192
+ process.kill(process.pid, signal)
193
+ }
194
+ process.on('exit', remove)
195
+ process.once('SIGINT', interrupt)
196
+ process.once('SIGTERM', interrupt)
197
+ return { folder, remove }
198
+ }
199
+
200
+ /**
201
+ * Writes the clip from the spool, one frame per slot of the stage clock, so its
202
+ * length is the take's rather than whatever the frames span. Refuses when no
203
+ * frame arrived, since there is nothing true to show.
204
+ */
205
+ async function encodeSpool(
206
+ spool: readonly SpooledFrame[],
207
+ clip: string,
208
+ startMs: number,
209
+ lengthMs: number,
210
+ ): Promise<void> {
211
+ const slots = slotFrames(
212
+ spool.map((frame) => frame.stamp),
213
+ { startMs, lengthMs, frameMs: FRAME_MS },
214
+ )
215
+ if (slots.length === 0) {
216
+ throw new Error(
217
+ `the screencast delivered no frame in ${(lengthMs / 1000).toFixed(2)} s of filming`,
218
+ )
219
+ }
220
+ const encoder = openFrameEncoder(clip, FPS)
221
+ try {
222
+ let shown = -1
223
+ let jpeg = Buffer.alloc(0)
224
+ for (const index of slots) {
225
+ const frame = spool[index]
226
+ if (frame && index !== shown) {
227
+ jpeg = readFileSync(frame.path)
228
+ shown = index
229
+ }
230
+ await encoder.write(jpeg)
231
+ }
232
+ await encoder.close()
233
+ } catch (error) {
234
+ encoder.abort()
235
+ throw error
236
+ }
237
+ }
238
+
239
+ /**
240
+ * The page time of its next animation frame, or undefined when none comes
241
+ * within `capMs`, as on a hidden page, or the page closed first.
242
+ */
243
+ function nextFrame(page: Page, capMs: number): Promise<number | undefined> {
244
+ return withinCap(
245
+ page
246
+ .evaluate(
247
+ () => new Promise<number>((done) => requestAnimationFrame(done)),
248
+ )
249
+ .catch(() => undefined),
250
+ capMs,
251
+ )
252
+ }
253
+
254
+ /** Where the calibration's armed listener hands back the frame it painted in. */
255
+ const PAINTED = '__canonTakePainted'
256
+
257
+ /**
258
+ * How long a move takes to show in the clip, measured by sending one aligned
259
+ * move to `START` and reading the animation frame after its handler against
260
+ * the frame it was aligned to. Both readings are page time, so no clock mapping
261
+ * is needed. The screencast captures a frame once it is presented, one page
262
+ * frame after that frame begins, so the lag counts one page frame past the
263
+ * readings, read off two consecutive animation frames rather than assumed from
264
+ * the clip's rate. That held a dragged box within 7 px on every run where the
265
+ * readings alone left 10 to 15. Undefined when the page gives no frame to read,
266
+ * and the pointer rests on `START` either way.
267
+ */
268
+ async function calibrate(page: Page): Promise<number | undefined> {
269
+ await page.mouse.move(START.x - 1, START.y)
270
+ const interval = await withinCap(
271
+ page
272
+ .evaluate(
273
+ () =>
274
+ new Promise<number>((done) =>
275
+ requestAnimationFrame((first) =>
276
+ requestAnimationFrame((second) => done(second - first)),
277
+ ),
278
+ ),
279
+ )
280
+ .catch(() => undefined),
281
+ 2 * ALIGN_CAP_MS,
282
+ )
283
+ await page.evaluate((key) => {
284
+ const slot = window as unknown as Record<string, unknown>
285
+ slot[key] = new Promise<number>((done) => {
286
+ addEventListener('pointermove', () => requestAnimationFrame(done), {
287
+ once: true,
288
+ capture: true,
289
+ })
290
+ })
291
+ }, PAINTED)
292
+ const aligned = await nextFrame(page, ALIGN_CAP_MS)
293
+ await page.mouse.move(START.x, START.y)
294
+ const painted = await withinCap(
295
+ page
296
+ .evaluate((key) => {
297
+ const slot = window as unknown as Record<string, unknown>
298
+ const armed = slot[key] as Promise<number>
299
+ delete slot[key]
300
+ return armed
301
+ }, PAINTED)
302
+ .catch(() => undefined),
303
+ CALIBRATE_CAP_MS,
304
+ )
305
+ if (interval === undefined || aligned === undefined) return undefined
306
+ if (painted === undefined) return undefined
307
+ return Math.max(0, painted - aligned) + interval
308
+ }
309
+
310
+ /** An event moved `lag` later on the clip, with every point it sent. */
311
+ function shifted(event: LogEvent, lag: number): LogEvent {
312
+ if (event.kind === 'move' && event.points) {
313
+ return {
314
+ ...event,
315
+ t: event.t + lag,
316
+ points: event.points.map((point) => ({ ...point, t: point.t + lag })),
317
+ }
318
+ }
319
+ return { ...event, t: event.t + lag }
320
+ }
321
+
112
322
  async function send(mouse: Mouse, input: PointerInput): Promise<void> {
113
323
  switch (input.type) {
114
324
  case 'move':
@@ -187,10 +187,10 @@ export async function filmStepped(
187
187
  async function frameStill(
188
188
  beginFrame: (screenshot: boolean) => Promise<{ screenshotData?: string }>,
189
189
  ): Promise<Buffer | undefined> {
190
- const reply = await Promise.race([
190
+ const reply = await withinCap(
191
191
  beginFrame(true).catch(() => undefined),
192
- sleep(STILL_CAP_MS),
193
- ])
192
+ STILL_CAP_MS,
193
+ )
194
194
  return reply?.screenshotData
195
195
  ? Buffer.from(reply.screenshotData, 'base64')
196
196
  : undefined
@@ -214,7 +214,7 @@ async function load(page: ShellPage, url: string): Promise<number> {
214
214
  if (navigated.errorText) {
215
215
  throw new Error(`${navigated.errorText} at ${url}`)
216
216
  }
217
- const timestamp = await Promise.race([loaded, sleep(LOAD_CAP_MS)])
217
+ const timestamp = await withinCap(loaded, LOAD_CAP_MS)
218
218
  if (typeof timestamp !== 'number') {
219
219
  throw new Error(
220
220
  `${url} fired no load event within ${LOAD_CAP_MS / 1000} s, so no frame clock could be read`,
@@ -381,6 +381,26 @@ async function withinStall<T>(
381
381
  }
382
382
  }
383
383
 
384
+ /**
385
+ * Races `work` against a cap and resolves empty when the cap wins. The timer
386
+ * is cleared once the race settles, since a pending one keeps the process alive
387
+ * for the whole cap after filming ends.
388
+ */
389
+ export async function withinCap<T>(
390
+ work: Promise<T>,
391
+ ms: number,
392
+ ): Promise<T | undefined> {
393
+ let timer: ReturnType<typeof setTimeout> | undefined
394
+ const capped = new Promise<undefined>((done) => {
395
+ timer = setTimeout(() => done(undefined), ms)
396
+ })
397
+ try {
398
+ return await Promise.race([work, capped])
399
+ } finally {
400
+ clearTimeout(timer)
401
+ }
402
+ }
403
+
384
404
  function sleep(ms: number): Promise<void> {
385
405
  return new Promise((done) => setTimeout(done, ms))
386
406
  }
@@ -0,0 +1,198 @@
1
+ import { execa } from 'execa'
2
+ import { gitEnv } from '@/git/env'
3
+ import {
4
+ repositoryOf,
5
+ resolveSessions,
6
+ type SessionReport,
7
+ } from '@/sessions/resolve'
8
+
9
+ /**
10
+ * Bounds the one network read `canon tasks validate --live` takes, shorter than
11
+ * the archive's own, since the sweep waits on it and a slow answer reads the
12
+ * same as no answer.
13
+ */
14
+ const GH_TIMEOUT_MS = 5_000
15
+
16
+ /** `gh pr list` caps at 30 by default, and a truncated list would read as clean. */
17
+ const PULL_LIMIT = 1_000
18
+
19
+ /**
20
+ * Who holds each branch, named for a person reading the report. `undefined`
21
+ * means the source could not be read, which has to stay apart from an empty map:
22
+ * an unreachable source that degraded to "nobody holds it" would report a held
23
+ * row as clean.
24
+ */
25
+ export type HolderReader = () => Promise<
26
+ ReadonlyMap<string, string> | undefined
27
+ >
28
+
29
+ /** Whether a merged pull request names a branch, `undefined` when GitHub could not say. */
30
+ export type MergedReader = (branch: string) => Promise<boolean | undefined>
31
+
32
+ export interface LiveReaders {
33
+ readonly sessions: HolderReader
34
+ readonly pulls: HolderReader
35
+ readonly merged: MergedReader
36
+ }
37
+
38
+ export interface SessionHolderOptions {
39
+ readonly resolve?: () => Promise<SessionReport>
40
+ readonly repository?: (root: string) => Promise<string | null>
41
+ }
42
+
43
+ /**
44
+ * Reads the roster scoped to `root`'s repository, since a branch name says
45
+ * nothing across a machine and another project's worker on the same name holds
46
+ * nothing here. Two rows leave the liveness undecided, a foreign record whose
47
+ * socket is gone and a row resting only on a socket file a crash can leave, so
48
+ * either reads the whole roster as unread rather than as a hold or as clear.
49
+ */
50
+ export function sessionHolders(
51
+ root: string,
52
+ opts: SessionHolderOptions = {},
53
+ ): HolderReader {
54
+ const resolve = opts.resolve ?? resolveSessions
55
+ const repository = opts.repository ?? repositoryOf
56
+
57
+ return async () => {
58
+ try {
59
+ const [report, repo] = await Promise.all([resolve(), repository(root)])
60
+ if (
61
+ report.kind !== 'resolved' ||
62
+ report.foreignDead > 0 ||
63
+ report.sessions.some((session) => session.via === 'socket') ||
64
+ !repo
65
+ ) {
66
+ return undefined
67
+ }
68
+
69
+ return new Map(
70
+ report.sessions.flatMap((session) =>
71
+ session.repository === repo && session.branch
72
+ ? [[session.branch, `session ${session.name}`] as const]
73
+ : [],
74
+ ),
75
+ )
76
+ } catch {
77
+ return undefined
78
+ }
79
+ }
80
+ }
81
+
82
+ export type GhRunner = (
83
+ cwd: string,
84
+ args: readonly string[],
85
+ ) => Promise<string | undefined>
86
+
87
+ const runGh: GhRunner = async (cwd, args) => {
88
+ if (Bun.which('gh') === null) return undefined
89
+
90
+ try {
91
+ // `gh` resolves its repository through git, so a hook's resolution
92
+ // variables would point it at another repository than `cwd`.
93
+ const result = await execa('gh', [...args], {
94
+ cwd,
95
+ timeout: GH_TIMEOUT_MS,
96
+ env: gitEnv(),
97
+ extendEnv: false,
98
+ })
99
+ return result.stdout
100
+ } catch {
101
+ return undefined
102
+ }
103
+ }
104
+
105
+ interface ListedPull {
106
+ readonly number: number
107
+ readonly headRefName: string
108
+ readonly isCrossRepository: boolean
109
+ }
110
+
111
+ function isListedPull(value: unknown): value is ListedPull {
112
+ if (typeof value !== 'object' || value === null) return false
113
+ const pull = value as Record<string, unknown>
114
+
115
+ return (
116
+ typeof pull.number === 'number' &&
117
+ typeof pull.headRefName === 'string' &&
118
+ typeof pull.isCrossRepository === 'boolean'
119
+ )
120
+ }
121
+
122
+ function parsePulls(stdout: string): readonly ListedPull[] | undefined {
123
+ try {
124
+ const parsed: unknown = JSON.parse(stdout)
125
+ return Array.isArray(parsed) && parsed.every(isListedPull)
126
+ ? parsed
127
+ : undefined
128
+ } catch {
129
+ return undefined
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Reads open pull requests off GitHub. A list reaching the limit may have cut
135
+ * off the one that holds a row, so it reads as unread. A fork's head names a
136
+ * branch in another repository, which holds nothing here however it is spelled.
137
+ */
138
+ export function pullHolders(root: string, run: GhRunner = runGh): HolderReader {
139
+ return async () => {
140
+ const stdout = await run(root, [
141
+ 'pr',
142
+ 'list',
143
+ '--state',
144
+ 'open',
145
+ '--limit',
146
+ String(PULL_LIMIT),
147
+ '--json',
148
+ 'number,headRefName,isCrossRepository',
149
+ ])
150
+ if (stdout === undefined) return undefined
151
+
152
+ const pulls = parsePulls(stdout)
153
+ if (!pulls || pulls.length >= PULL_LIMIT) return undefined
154
+
155
+ return new Map(
156
+ pulls
157
+ .filter((pull) => !pull.isCrossRepository)
158
+ .map((pull) => [pull.headRefName, `pull request #${pull.number}`]),
159
+ )
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Asks about one branch at a time rather than listing merged pull requests,
165
+ * since a list long enough to reach an old merge costs every run, and only a
166
+ * branch a session already holds against a row takes this read.
167
+ */
168
+ export function mergedBranches(
169
+ root: string,
170
+ run: GhRunner = runGh,
171
+ ): MergedReader {
172
+ return async (branch) => {
173
+ const stdout = await run(root, [
174
+ 'pr',
175
+ 'list',
176
+ '--state',
177
+ 'merged',
178
+ '--head',
179
+ branch,
180
+ '--limit',
181
+ String(PULL_LIMIT),
182
+ '--json',
183
+ 'number,headRefName,isCrossRepository',
184
+ ])
185
+ if (stdout === undefined) return undefined
186
+
187
+ const pulls = parsePulls(stdout)
188
+ return pulls?.some((pull) => !pull.isCrossRepository)
189
+ }
190
+ }
191
+
192
+ export function liveReaders(root: string): LiveReaders {
193
+ return {
194
+ sessions: sessionHolders(root),
195
+ pulls: pullHolders(root),
196
+ merged: mergedBranches(root),
197
+ }
198
+ }