@erclx/canon 5.78.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 (56) hide show
  1. package/CHANGELOG.md +23 -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/git-followup/SKILL.md +1 -0
  12. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  13. package/claude/skills/git-pr/SKILL.md +2 -0
  14. package/claude/skills/git-pr/references/final-command.md +34 -2
  15. package/claude/skills/git-pr/references/pr.md +1 -1
  16. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  17. package/claude/skills/git-ship/SKILL.md +16 -11
  18. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  19. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  20. package/claude/skills/plan-feature/references/plan.md +2 -0
  21. package/claude/skills/review-address/SKILL.md +2 -1
  22. package/claude/skills/review-pr/references/key-changes.md +2 -1
  23. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  24. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  25. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  26. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  27. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  28. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  29. package/claude/skills/role-worker/SKILL.md +1 -1
  30. package/claude/skills/task-board/SKILL.md +1 -0
  31. package/claude/skills/task-board/references/tasks.md +1 -0
  32. package/docs/workflow/ai-workflow.md +1 -1
  33. package/docs/workflow/parallel-features.md +2 -1
  34. package/package.json +1 -1
  35. package/src/commands/pr.ts +138 -15
  36. package/src/commands/sessions.ts +88 -6
  37. package/src/commands/tasks.ts +41 -2
  38. package/src/git/files.ts +5 -2
  39. package/src/indexes/frontmatter.ts +27 -6
  40. package/src/indexes/render.ts +21 -3
  41. package/src/markdown/colons.ts +14 -4
  42. package/src/markdown/heading-case.ts +9 -2
  43. package/src/pr/bijection.ts +8 -0
  44. package/src/pr/evidence.ts +38 -9
  45. package/src/pr/health.ts +63 -15
  46. package/src/pr/links.ts +160 -0
  47. package/src/pr/local.ts +17 -4
  48. package/src/pr/paths.ts +20 -2
  49. package/src/sessions/stop.ts +145 -0
  50. package/src/sessions/transfer/bundle.ts +1 -1
  51. package/src/take/encode.ts +16 -42
  52. package/src/take/frame-clock.ts +46 -0
  53. package/src/take/screencast.ts +114 -11
  54. package/src/tasks/holders.ts +198 -0
  55. package/src/tasks/validate.ts +148 -0
  56. /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,
@@ -51,8 +51,13 @@ export async function filmScreencast(request: {
51
51
  readonly beat: Beat
52
52
  readonly clip: string
53
53
  }): Promise<ScreencastTake> {
54
- const scratch = mkdtempSync(join(tmpdir(), 'canon-take-cast-'))
55
- 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
+ })
56
61
  let still: (() => Promise<Buffer | undefined>) | undefined
57
62
  try {
58
63
  const page = await browser.newPage({ viewport: VIEWPORT })
@@ -97,9 +102,33 @@ export async function filmScreencast(request: {
97
102
  if (request.beat.setup.length > 0) await live.hold(1000)
98
103
  const lag = await calibrate(page)
99
104
 
100
- const webm = join(scratch, 'take.webm')
101
105
  const events: LogEvent[] = []
102
- 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
+ })
103
132
  const started = performance.now()
104
133
  const recording = stageOn(() => performance.now() - started)
105
134
  await recording.hold(HOLD_MS)
@@ -110,8 +139,9 @@ export async function filmScreencast(request: {
110
139
  await recording.hold(HOLD_MS)
111
140
  const filmedMs = recording.now()
112
141
  await page.screencast.stop()
142
+ if (spoolFailure !== undefined) throw spoolFailure
113
143
 
114
- await transcode(webm, request.clip, FPS, filmedMs)
144
+ await encodeSpool(spool, request.clip, started, filmedMs)
115
145
  const frames = await probeFrames(request.clip)
116
146
  const closing = await lastFrame(request.clip, frames)
117
147
  if (lag === undefined) return { frames, events, still: closing }
@@ -126,13 +156,86 @@ export async function filmScreencast(request: {
126
156
  throw error
127
157
  } finally {
128
158
  await browser.close()
129
- rmSync(scratch, { recursive: true, force: true })
159
+ scratch.remove()
130
160
  }
131
161
  }
132
162
 
133
163
  type Page = import('playwright-core').Page
134
164
  type Mouse = import('playwright-core').Mouse
135
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
+
136
239
  /**
137
240
  * The page time of its next animation frame, or undefined when none comes
138
241
  * within `capMs`, as on a hidden page, or the page closed first.
@@ -155,7 +258,7 @@ const PAINTED = '__canonTakePainted'
155
258
  * How long a move takes to show in the clip, measured by sending one aligned
156
259
  * move to `START` and reading the animation frame after its handler against
157
260
  * the frame it was aligned to. Both readings are page time, so no clock mapping
158
- * is needed. The recorder captures a frame once it is presented, one page
261
+ * is needed. The screencast captures a frame once it is presented, one page
159
262
  * frame after that frame begins, so the lag counts one page frame past the
160
263
  * readings, read off two consecutive animation frames rather than assumed from
161
264
  * the clip's rate. That held a dragged box within 7 px on every run where the
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
2
  import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join, resolve } from 'node:path'
4
+ import { readFrontmatterSource } from '@/indexes/frontmatter'
4
5
  import { codeSpans, unbrokenRun } from '@/markdown/tables'
5
6
  import {
6
7
  archiveDir,
@@ -21,6 +22,8 @@ import {
21
22
  renderedLength,
22
23
  WAITING_LIMIT,
23
24
  } from '@/tasks/board'
25
+ import { branchOfPlan } from '@/tasks/branch'
26
+ import type { HolderReader, LiveReaders, MergedReader } from '@/tasks/holders'
24
27
  import { gitTrunkReader, type TrunkReader } from '@/tasks/trunk'
25
28
 
26
29
  const ORDERING_FILE = 'priority.md'
@@ -61,6 +64,7 @@ export const FINDING_KINDS = [
61
64
  'row-untabled',
62
65
  'row-misordered',
63
66
  'row-unranked',
67
+ 'row-held',
64
68
  'touches-unstated',
65
69
  'touches-stale',
66
70
  'columns-mismatched',
@@ -69,6 +73,7 @@ export const FINDING_KINDS = [
69
73
  'blocker-unresolved',
70
74
  'blocker-declined',
71
75
  'sessions-misshapen',
76
+ 'frontmatter-invalid',
72
77
  ] as const
73
78
 
74
79
  export type FindingKind = (typeof FINDING_KINDS)[number]
@@ -177,6 +182,18 @@ export interface BoardRow {
177
182
  readonly cells: readonly string[]
178
183
  }
179
184
 
185
+ /**
186
+ * What each source behind the held-row check answered. `unread` is a source the
187
+ * run asked and could not read, so a board carrying no `row-held` finding beside
188
+ * it is untested rather than clean. `not-asked` is a run without the live read.
189
+ */
190
+ export type LiveState = 'read' | 'unread' | 'not-asked'
191
+
192
+ export interface LiveReading {
193
+ readonly sessions: LiveState
194
+ readonly pulls: LiveState
195
+ }
196
+
180
197
  export interface ValidateReport {
181
198
  readonly ok: true
182
199
  readonly rows: number
@@ -189,6 +206,7 @@ export interface ValidateReport {
189
206
  readonly shared: readonly SharedFiles[]
190
207
  readonly wide: readonly WideToken[]
191
208
  readonly unplaced: readonly Unplaced[]
209
+ readonly live: LiveReading
192
210
  }
193
211
 
194
212
  export interface ValidateRefused {
@@ -877,6 +895,34 @@ async function checkSessions(
877
895
  return found.filter((finding): finding is Finding => finding !== undefined)
878
896
  }
879
897
 
898
+ /**
899
+ * Reports a task file whose frontmatter block will not parse, which the index
900
+ * regeneration would otherwise surface only after the row was placed. A file
901
+ * with no block is left alone, since the index walker owns that report.
902
+ */
903
+ async function checkFrontmatter(
904
+ stems: readonly string[],
905
+ dir: string,
906
+ ): Promise<Finding[]> {
907
+ const found = await Promise.all(
908
+ stems.map(async (stem): Promise<Finding | undefined> => {
909
+ const reading = readFrontmatterSource(
910
+ await readFile(join(dir, `${stem}.md`), 'utf8'),
911
+ )
912
+ if (reading.kind !== 'invalid') return undefined
913
+
914
+ return {
915
+ kind: 'frontmatter-invalid',
916
+ group: undefined,
917
+ subject: stem,
918
+ message: `frontmatter is not valid YAML: ${reading.message}. Double any apostrophe inside the single-quoted title.`,
919
+ }
920
+ }),
921
+ )
922
+
923
+ return found.filter((finding): finding is Finding => finding !== undefined)
924
+ }
925
+
880
926
  /**
881
927
  * Tests the claim a group name makes about a plan. `## Needs a plan` says none
882
928
  * is live and `## Up next` says one is written, and neither group has a `Plan`
@@ -1611,6 +1657,95 @@ export async function groupRuleFindings(
1611
1657
  ]
1612
1658
  }
1613
1659
 
1660
+ /**
1661
+ * Reports a row outside `## Run now` whose task a live session or an open pull
1662
+ * request holds, tied through the branch each live plan on its `Plan:` line
1663
+ * derives. A row whose worker renamed its branch has nothing to match, and an
1664
+ * archived plan's branch is finished work, so neither is reported.
1665
+ *
1666
+ * A session outlives its merge, so a session still on a branch a merged pull
1667
+ * request names is finished work too, until the merge reaches this clone and the
1668
+ * hook archives the row. A merged read that fails leaves the hold standing, since
1669
+ * the session is still there, and reads `pulls` as unread, since that hold may be
1670
+ * finished work the open list could not rule out.
1671
+ */
1672
+ async function checkHeld(
1673
+ rows: readonly BoardRow[],
1674
+ live: LiveRead,
1675
+ dir: string,
1676
+ root: string,
1677
+ ): Promise<{ readonly findings: Finding[]; readonly mergedUnread: boolean }> {
1678
+ let mergedUnread = false
1679
+
1680
+ const holdersOf = async (branch: string): Promise<string[]> => {
1681
+ const session = live.sessions?.get(branch)
1682
+ const merged = session ? await live.merged?.(branch) : false
1683
+ if (merged === undefined) mergedUnread = true
1684
+
1685
+ return [session && !merged ? session : undefined, live.pulls?.get(branch)]
1686
+ .filter((name): name is string => name !== undefined)
1687
+ .map((name) => `${name} on ${branch}`)
1688
+ }
1689
+
1690
+ const found = await Promise.all(
1691
+ rows.map(async (row): Promise<Finding | undefined> => {
1692
+ if (row.group === 'Run now' || !row.stem) return undefined
1693
+ const file = join(dir, `${row.stem}.md`)
1694
+ if (!existsSync(file)) return undefined
1695
+
1696
+ const targets = readPlanTargets(await readFile(file, 'utf8'))
1697
+ const branches = targets.flatMap((target) => {
1698
+ const path = target.split('#')[0] || target
1699
+ return resolveLivePlan(path, dir, root) === undefined
1700
+ ? []
1701
+ : [branchOfPlan(path)]
1702
+ })
1703
+ const held = (await Promise.all(branches.map(holdersOf))).flat()
1704
+ if (held.length === 0) return undefined
1705
+
1706
+ return {
1707
+ kind: 'row-held',
1708
+ group: row.group,
1709
+ subject: row.stem,
1710
+ message: `sits under ${row.group} and is held by ${held.join(' and ')}. A row with a worker belongs under Run now.`,
1711
+ }
1712
+ }),
1713
+ )
1714
+
1715
+ return {
1716
+ findings: found.filter(
1717
+ (finding): finding is Finding => finding !== undefined,
1718
+ ),
1719
+ mergedUnread,
1720
+ }
1721
+ }
1722
+
1723
+ /** What each source answered, `undefined` where it was not asked or failed. */
1724
+ interface LiveRead {
1725
+ readonly reading: LiveReading
1726
+ readonly sessions?: Awaited<ReturnType<HolderReader>>
1727
+ readonly pulls?: Awaited<ReturnType<HolderReader>>
1728
+ readonly merged?: MergedReader
1729
+ }
1730
+
1731
+ /** Takes each source once, since a failed read answers undefined rather than throws. */
1732
+ async function readLive(live: LiveReaders | undefined): Promise<LiveRead> {
1733
+ if (!live) {
1734
+ return { reading: { sessions: 'not-asked', pulls: 'not-asked' } }
1735
+ }
1736
+
1737
+ const stateOf = (read: Awaited<ReturnType<HolderReader>>): LiveState =>
1738
+ read ? 'read' : 'unread'
1739
+ const [sessions, pulls] = await Promise.all([live.sessions(), live.pulls()])
1740
+
1741
+ return {
1742
+ reading: { sessions: stateOf(sessions), pulls: stateOf(pulls) },
1743
+ sessions,
1744
+ pulls,
1745
+ merged: live.merged,
1746
+ }
1747
+ }
1748
+
1614
1749
  function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
1615
1750
  return { ok: false, reason, message }
1616
1751
  }
@@ -1618,6 +1753,12 @@ function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
1618
1753
  export interface ValidateOptions {
1619
1754
  /** Overridden by tests, which supply the trunk rather than reaching for git. */
1620
1755
  readonly trunk?: TrunkReader
1756
+ /**
1757
+ * The roster and GitHub reads behind the held-row check. Absent by default,
1758
+ * since the board's write hook runs this on every edit and a network read
1759
+ * there costs every write.
1760
+ */
1761
+ readonly live?: LiveReaders
1621
1762
  }
1622
1763
 
1623
1764
  /**
@@ -1668,6 +1809,8 @@ export async function validateBoard(
1668
1809
  ? await listTaskStems(declinedPath)
1669
1810
  : []
1670
1811
  const parked = await checkParked(rows, root, trunk)
1812
+ const live = await readLive(options.live)
1813
+ const held = await checkHeld(rows, live, dir, root)
1671
1814
 
1672
1815
  const findings = [
1673
1816
  ...shapeFindings,
@@ -1675,10 +1818,12 @@ export async function validateBoard(
1675
1818
  ...(await groupRuleFindings(rows, root)),
1676
1819
  ...(await checkPlanAgreement(rows, dir, root)),
1677
1820
  ...(await checkSessions(rows, dir)),
1821
+ ...(await checkFrontmatter(stems, dir)),
1678
1822
  ...(await checkStaleTouches(rows, root)),
1679
1823
  ...checkCellWidth(rows),
1680
1824
  ...checkOrdering(rows),
1681
1825
  ...parked.findings,
1826
+ ...held.findings,
1682
1827
  ]
1683
1828
 
1684
1829
  return {
@@ -1693,5 +1838,8 @@ export async function validateBoard(
1693
1838
  shared: checkSharedFiles(rows),
1694
1839
  wide: checkWideTokens(rows),
1695
1840
  unplaced: checkUnplaced(rows, backlog, stems),
1841
+ live: held.mergedUnread
1842
+ ? { ...live.reading, pulls: 'unread' }
1843
+ : live.reading,
1696
1844
  }
1697
1845
  }