@erclx/canon 5.46.0 → 5.47.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 (36) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/git-followup/SKILL.md +1 -0
  3. package/claude/skills/git-pr/REQUIREMENT.md +2 -2
  4. package/claude/skills/git-pr/SKILL.md +2 -1
  5. package/claude/skills/git-pr/references/pr.md +58 -7
  6. package/claude/skills/review-address/SKILL.md +18 -6
  7. package/claude/skills/review-address/references/post-review-findings.md +4 -1
  8. package/claude/skills/review-address/references/rebase-only.md +14 -2
  9. package/claude/skills/review-pr/SKILL.md +9 -2
  10. package/claude/skills/review-pr/references/body-variants.md +29 -8
  11. package/claude/skills/review-pr/references/post.md +29 -10
  12. package/claude/skills/review-ui/SKILL.md +1 -0
  13. package/claude/skills/review-ui/references/post.md +2 -0
  14. package/claude/skills/review-ui/references/tick.md +3 -0
  15. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  16. package/claude/skills/role-reviewer/SKILL.md +3 -0
  17. package/claude/skills/role-worker/REQUIREMENT.md +1 -1
  18. package/claude/skills/role-worker/SKILL.md +1 -0
  19. package/claude/skills/task-board/references/decline.md +2 -2
  20. package/claude/skills/task-complete/references/archive.md +3 -2
  21. package/claude/skills/ui-checklist/SKILL.md +3 -3
  22. package/docs/workflow/zshrc-aliases.md +3 -0
  23. package/package.json +1 -1
  24. package/scripts/core/bootstrap.sh +1 -0
  25. package/src/commands/labels.ts +66 -1
  26. package/src/commands/pr.ts +113 -4
  27. package/src/commands/sessions.ts +25 -11
  28. package/src/commands/tasks.ts +2 -0
  29. package/src/labels/density.ts +363 -0
  30. package/src/pr/evidence.ts +1 -1
  31. package/src/pr/rest.ts +2 -0
  32. package/src/pr/tick.ts +89 -0
  33. package/src/sessions/live.ts +26 -0
  34. package/src/sessions/resolve.ts +62 -11
  35. package/src/tasks/archive.ts +15 -4
  36. package/tooling/base/seeds/.github/pull_request_template.md +1 -1
@@ -0,0 +1,363 @@
1
+ import {
2
+ type DensityFinding,
3
+ type DensityKind,
4
+ densityFindings,
5
+ } from '@/markdown/density'
6
+ import { type BodyLine, bodyLines, visibleText } from '@/markdown/scan'
7
+ import { CHECKPOINTS, LIST_ITEM } from '@/markdown/structure'
8
+
9
+ /**
10
+ * The four counted limits a pull request section carries. The heading limits
11
+ * of `densityFindings()` are left out, since a body takes no subheading at all
12
+ * and `subheading` reports that once.
13
+ */
14
+ const COUNTED = [
15
+ 'paragraph-length',
16
+ 'paragraph-run',
17
+ 'bullet-length',
18
+ 'bullet-run',
19
+ ] as const satisfies readonly DensityKind[]
20
+
21
+ type CountedKind = (typeof COUNTED)[number]
22
+
23
+ export type BodyDensityKind = CountedKind | 'subheading' | 'mixed-form'
24
+
25
+ function isCounted(kind: DensityKind): kind is CountedKind {
26
+ return (COUNTED as readonly DensityKind[]).includes(kind)
27
+ }
28
+
29
+ export interface BodyDensityFinding {
30
+ /** The H2 the finding sits under, without its hashes, or null before the first. */
31
+ readonly section: string | null
32
+ readonly kind: BodyDensityKind
33
+ /** The line in the whole body, not in its section. */
34
+ readonly line: number
35
+ readonly value: number | null
36
+ readonly limit: number | null
37
+ }
38
+
39
+ const H2 = /^##\s+(.*?)\s*$/
40
+ const SUBHEADING = /^(#{3,6})\s/
41
+ const BOLD_ONLY = /^(\*\*|__)[^*_].*\1\s*$/
42
+ const BOLD_LED = /^(\*\*|__)\S/
43
+
44
+ interface Section {
45
+ readonly name: string | null
46
+ readonly lines: readonly BodyLine[]
47
+ }
48
+
49
+ /**
50
+ * Every density breach in a pull request body, graded per H2 section after
51
+ * the shape `claude/skills/git-pr/references/pr.md` states.
52
+ *
53
+ * `densityFindings()` stays the docs measure, so the PR exemptions live here
54
+ * as splits around it rather than as options on it. Testing takes no counted
55
+ * limit, since its checkboxes count as a bullet run. Key changes splits at
56
+ * each bold label alone on its own block, whatever its length, and Technical context
57
+ * at each bold-led paragraph, so a lead-in starts a new run. Technical
58
+ * context also reports mixing bullets with paragraphs.
59
+ */
60
+ export function bodyDensity(body: string): BodyDensityFinding[] {
61
+ const found: BodyDensityFinding[] = []
62
+
63
+ for (const section of sectionsOf(bodyLines(body))) {
64
+ found.push(...subheadings(section))
65
+ if (section.name === 'Testing') continue
66
+
67
+ const chunks =
68
+ section.name === 'Key changes'
69
+ ? splitAt(section.lines, opensBlock(section.lines, BOLD_ONLY), true)
70
+ : section.name === 'Technical context'
71
+ ? splitAt(section.lines, opensBlock(section.lines, BOLD_LED), false)
72
+ : [section.lines]
73
+
74
+ for (const chunk of chunks) {
75
+ for (const finding of densityFindings(chunk)) {
76
+ const { kind } = finding
77
+ if (!isCounted(kind)) continue
78
+ found.push({ section: section.name, ...finding, kind })
79
+ }
80
+ }
81
+
82
+ if (section.name === 'Technical context') {
83
+ const mixed = mixedForm(section.lines)
84
+ if (mixed !== undefined) {
85
+ found.push({
86
+ section: section.name,
87
+ kind: 'mixed-form',
88
+ line: mixed,
89
+ value: null,
90
+ limit: null,
91
+ })
92
+ }
93
+ }
94
+ }
95
+
96
+ return found.sort((a, b) => a.line - b.line)
97
+ }
98
+
99
+ /**
100
+ * The opening headings of the posts a canon skill writes to a pull request,
101
+ * after `.canon/groundwork/170-github-comment-density/04-post-catalog.md`. A
102
+ * body opening on any other heading is not a post this reader grades.
103
+ */
104
+ export const POST_HEADINGS = [
105
+ 'Evidence',
106
+ 'Review',
107
+ 'Review closed',
108
+ 'UI review',
109
+ 'UI review closed',
110
+ 'Review response',
111
+ 'Post-review findings',
112
+ 'Rebase',
113
+ ] as const
114
+
115
+ export type PostDensityKind = CountedKind | 'heading-depth'
116
+
117
+ export interface PostDensityFinding extends Omit<BodyDensityFinding, 'kind'> {
118
+ readonly kind: PostDensityKind
119
+ }
120
+
121
+ const LENGTHS: readonly DensityKind[] = ['paragraph-length', 'bullet-length']
122
+ const NESTED_ITEM = /^(\s+)(?:[-*+]|\d+\.)\s+(.*)$/
123
+ const CHECKBOX = /^\s*[-*+]\s+\[[ xX]\]/
124
+ const FOOTER = /^🤖/
125
+
126
+ /**
127
+ * Every density breach in a post a skill writes to a pull request, keyed on
128
+ * its opening H2, or none when that heading is not in `POST_HEADINGS`.
129
+ *
130
+ * A post allows H3s, which the UI review groups its verdicts under, so only a
131
+ * heading past H3 reports. The `🤖` footer and the marker never report, and a
132
+ * table never does. Inside `<details>` a block takes the length limits alone,
133
+ * since a reader opens it on purpose. A bold-led paragraph starts a new
134
+ * paragraph run, and a checklist box under `What to look at` takes no length
135
+ * limit, since a driver needs its route, action, and result.
136
+ *
137
+ * A nested item is measured as a bullet of its own, which `densityFindings()`
138
+ * leaves out of its parent, so a finding's subject and its evidence never sum.
139
+ */
140
+ export function postDensity(body: string): PostDensityFinding[] {
141
+ const lines = bodyLines(body).map((line) =>
142
+ !line.fenced && FOOTER.test(line.text) ? { ...line, text: '' } : line,
143
+ )
144
+ if (!opensOnPostHeading(lines)) return []
145
+
146
+ const found: PostDensityFinding[] = []
147
+ const text = new Map(lines.map((line) => [line.number, line.text]))
148
+
149
+ for (const section of sectionsOf(lines)) {
150
+ const { outside, inside } = foldedApart(section.lines)
151
+ const measured = [
152
+ ...splitAt(outside, opensBlock(outside, BOLD_LED), false).flatMap(
153
+ (chunk) => densityFindings(chunk),
154
+ ),
155
+ ...densityFindings(inside).filter(({ kind }) => LENGTHS.includes(kind)),
156
+ ...nestedLengths(section.lines),
157
+ ]
158
+
159
+ for (const finding of measured) {
160
+ const { kind } = finding
161
+ if (!isCounted(kind) && kind !== 'heading-depth') continue
162
+ if (
163
+ section.name === 'What to look at' &&
164
+ kind === 'bullet-length' &&
165
+ CHECKBOX.test(text.get(finding.line) ?? '')
166
+ ) {
167
+ continue
168
+ }
169
+ found.push({ section: section.name, ...finding, kind })
170
+ }
171
+ }
172
+
173
+ return found.sort((a, b) => a.line - b.line)
174
+ }
175
+
176
+ /** Whether the body's first H2 outside a fence is one of `POST_HEADINGS`. */
177
+ export function isPost(body: string): boolean {
178
+ return opensOnPostHeading(bodyLines(body))
179
+ }
180
+
181
+ function opensOnPostHeading(lines: readonly BodyLine[]): boolean {
182
+ const opening = lines.find((line) => !line.fenced && H2.test(line.text))
183
+ const heading = opening && H2.exec(opening.text)?.[1]
184
+ return (POST_HEADINGS as readonly string[]).includes(heading ?? '')
185
+ }
186
+
187
+ /**
188
+ * Parts a section into the lines outside `<details>` and the lines inside it.
189
+ * The tags stay outside, where each breaks a paragraph run as raw HTML does.
190
+ */
191
+ function foldedApart(lines: readonly BodyLine[]): {
192
+ outside: BodyLine[]
193
+ inside: BodyLine[]
194
+ } {
195
+ const outside: BodyLine[] = []
196
+ const inside: BodyLine[] = []
197
+ let depth = 0
198
+
199
+ for (const line of lines) {
200
+ const tag = line.fenced ? '' : line.text.trim()
201
+ if (tag.startsWith('<details')) {
202
+ depth++
203
+ outside.push(line)
204
+ } else if (tag.startsWith('</details')) {
205
+ depth = Math.max(0, depth - 1)
206
+ outside.push(line)
207
+ } else if (depth > 0) {
208
+ inside.push(line)
209
+ } else {
210
+ outside.push(line)
211
+ }
212
+ }
213
+
214
+ return { outside, inside }
215
+ }
216
+
217
+ /**
218
+ * A `bullet-length` finding for each nested item past the bullet limit, its
219
+ * length taken over the item and the lines indented under it that open no
220
+ * item of their own.
221
+ */
222
+ function nestedLengths(lines: readonly BodyLine[]): DensityFinding[] {
223
+ const found: DensityFinding[] = []
224
+
225
+ for (const [index, line] of lines.entries()) {
226
+ const match = line.fenced ? null : NESTED_ITEM.exec(line.text)
227
+ if (!match) continue
228
+
229
+ const indent = match[1].length
230
+ const parts = [match[2].trim()]
231
+ for (const next of lines.slice(index + 1)) {
232
+ const lead = /^\s*/.exec(next.text)?.[0].length ?? 0
233
+ if (next.fenced || next.text.trim() === '' || lead <= indent) break
234
+ if (LIST_ITEM.test(next.text)) break
235
+ parts.push(next.text.trim())
236
+ }
237
+
238
+ const length = visibleText(parts.join(' ')).length
239
+ if (length > CHECKPOINTS.bullet) {
240
+ found.push({
241
+ kind: 'bullet-length',
242
+ line: line.number,
243
+ value: length,
244
+ limit: CHECKPOINTS.bullet,
245
+ })
246
+ }
247
+ }
248
+
249
+ return found
250
+ }
251
+
252
+ function sectionsOf(lines: readonly BodyLine[]): Section[] {
253
+ const sections: Section[] = []
254
+ let current: { name: string | null; lines: BodyLine[] } = {
255
+ name: null,
256
+ lines: [],
257
+ }
258
+
259
+ for (const line of lines) {
260
+ const heading = line.fenced ? null : H2.exec(line.text)
261
+ if (heading) {
262
+ sections.push(current)
263
+ current = { name: heading[1], lines: [] }
264
+ } else {
265
+ current.lines.push(line)
266
+ }
267
+ }
268
+ sections.push(current)
269
+
270
+ return sections.filter((section) => section.lines.length > 0)
271
+ }
272
+
273
+ function subheadings(section: Section): BodyDensityFinding[] {
274
+ return section.lines.flatMap((line) => {
275
+ const match = line.fenced ? null : SUBHEADING.exec(line.text)
276
+ if (!match) return []
277
+ return [
278
+ {
279
+ section: section.name,
280
+ kind: 'subheading' as const,
281
+ line: line.number,
282
+ value: match[1].length,
283
+ limit: 2,
284
+ },
285
+ ]
286
+ })
287
+ }
288
+
289
+ /**
290
+ * Cuts a section into chunks at each line the test picks, never inside a
291
+ * fence. `drop` leaves the picked line out of both chunks, as a bold label
292
+ * that only names the group below it.
293
+ */
294
+ function splitAt(
295
+ lines: readonly BodyLine[],
296
+ breaks: (line: BodyLine, index: number) => boolean,
297
+ drop: boolean,
298
+ ): BodyLine[][] {
299
+ const chunks: BodyLine[][] = [[]]
300
+
301
+ for (const [index, line] of lines.entries()) {
302
+ if (!line.fenced && breaks(line, index)) {
303
+ chunks.push(drop ? [] : [line])
304
+ } else {
305
+ chunks[chunks.length - 1].push(line)
306
+ }
307
+ }
308
+
309
+ return chunks
310
+ }
311
+
312
+ /**
313
+ * Picks a line matching the pattern that opens a block, after a blank line or
314
+ * at the section start. A line directly under a bullet or a paragraph renders
315
+ * as its lazy continuation, so it breaks nothing.
316
+ */
317
+ function opensBlock(
318
+ lines: readonly BodyLine[],
319
+ pattern: RegExp,
320
+ ): (line: BodyLine, index: number) => boolean {
321
+ return (line, index) =>
322
+ pattern.test(line.text) &&
323
+ (index === 0 || lines[index - 1].text.trim() === '')
324
+ }
325
+
326
+ /**
327
+ * The line of the first block of whichever form appears second, when a
328
+ * section carries both bullets and paragraphs, else undefined.
329
+ *
330
+ * A column-zero line directly under an item with no blank between is a lazy
331
+ * continuation of that item, not a paragraph.
332
+ */
333
+ function mixedForm(lines: readonly BodyLine[]): number | undefined {
334
+ let firstItem: number | undefined
335
+ let firstParagraph: number | undefined
336
+ let previous: 'blank' | 'item' | 'paragraph' | 'other' = 'blank'
337
+
338
+ for (const line of lines) {
339
+ const text = line.text
340
+ if (line.fenced) {
341
+ previous = 'other'
342
+ continue
343
+ }
344
+ if (text.trim() === '') {
345
+ previous = 'blank'
346
+ continue
347
+ }
348
+ if (/^\s/.test(text)) continue
349
+
350
+ if (LIST_ITEM.test(text)) {
351
+ firstItem ??= line.number
352
+ previous = 'item'
353
+ } else if (/^(#|\||>|<)/.test(text)) {
354
+ previous = 'other'
355
+ } else if (previous === 'blank' || previous === 'other') {
356
+ firstParagraph ??= line.number
357
+ previous = 'paragraph'
358
+ }
359
+ }
360
+
361
+ if (firstItem === undefined || firstParagraph === undefined) return undefined
362
+ return Math.max(firstItem, firstParagraph)
363
+ }
@@ -5,7 +5,7 @@ const EVIDENCE_SEGMENT = 'evidence'
5
5
  const EVIDENCE_OPEN_LIMIT = 6
6
6
 
7
7
  /** Length of the commit names the visible line shows, the width `git` abbreviates to. */
8
- const SHORT_SHA_LENGTH = 7
8
+ export const SHORT_SHA_LENGTH = 7
9
9
 
10
10
  /** Prefix of the trailing marker this module writes and reads back. */
11
11
  const MARKER_PREFIX = '<!-- pr-evidence:'
package/src/pr/rest.ts CHANGED
@@ -42,6 +42,7 @@ export interface PullIdentity {
42
42
  readonly head?: string
43
43
  readonly base?: string
44
44
  readonly mergeState?: string
45
+ readonly body?: string
45
46
  }
46
47
 
47
48
  export type BranchPull =
@@ -70,6 +71,7 @@ export function identityOf(row: RestPull): PullIdentity {
70
71
  ...(row.head?.sha !== undefined && { head: row.head.sha }),
71
72
  ...(row.base?.ref !== undefined && { base: row.base.ref }),
72
73
  ...(mergeState !== undefined && { mergeState }),
74
+ ...(typeof row.body === 'string' && { body: row.body }),
73
75
  }
74
76
  }
75
77
 
package/src/pr/tick.ts CHANGED
@@ -3,9 +3,98 @@ import {
3
3
  CHECKLIST_START,
4
4
  parseBoxLine,
5
5
  readChecklistBoxes,
6
+ SHORT_SHA_LENGTH,
6
7
  writeBoxLine,
7
8
  } from '@/pr/evidence'
8
9
 
10
+ const UI_PASS_MARKER = '<!-- pr-ui-pass -->'
11
+ const TESTING_HEADING = /^## Testing\s*$/
12
+ const SECTION_HEADING = /^## /
13
+ const REVIEWER_HEADING = /^## For the reviewer\s*$/
14
+ const UI_PASS_HEAD = /at `([0-9a-f]+)`/
15
+
16
+ /** What the UI pass line reports, either a pass or a reset to a head nobody has driven. */
17
+ export type UiPassSummary =
18
+ | {
19
+ readonly kind: 'passed'
20
+ /** Driven boxes ticked, taste boxes excluded. */
21
+ readonly passed: number
22
+ /** Boxes a driver can run, taste boxes excluded. */
23
+ readonly total: number
24
+ readonly taste: number
25
+ readonly head: string
26
+ }
27
+ | { readonly kind: 'reset'; readonly head: string }
28
+
29
+ /**
30
+ * The one `## Testing` line a UI pass owns. It is ticked only when every box
31
+ * a driver can run passed, so a failed box never reads as done, and taste
32
+ * boxes are counted apart so a full pass never reads as one box failed.
33
+ */
34
+ export function uiPassLine(summary: UiPassSummary): string {
35
+ const at = `\`${summary.head.slice(0, SHORT_SHA_LENGTH)}\``
36
+ if (summary.kind === 'reset') {
37
+ return `- [ ] UI pass, not run at ${at} ${UI_PASS_MARKER}`
38
+ }
39
+ const taste =
40
+ summary.taste === 0
41
+ ? ''
42
+ : `${summary.taste} taste ${summary.taste === 1 ? 'box' : 'boxes'} left to the operator`
43
+ if (summary.total === 0) {
44
+ return `- [ ] UI pass, no box a driver can run, ${taste} at ${at} ${UI_PASS_MARKER}`
45
+ }
46
+ const box = summary.passed === summary.total ? 'x' : ' '
47
+ const tail = taste === '' ? '' : `, ${taste}`
48
+ return `- [${box}] UI pass, ${summary.passed} of ${summary.total} boxes passed at ${at}${tail} ${UI_PASS_MARKER}`
49
+ }
50
+
51
+ /** The short sha the marked line names, absent when the body carries no such line. */
52
+ export function readUiPassHead(body: string): string | undefined {
53
+ const line = body.split('\n').find((row) => row.includes(UI_PASS_MARKER))
54
+ return line === undefined ? undefined : UI_PASS_HEAD.exec(line)?.[1]
55
+ }
56
+
57
+ /**
58
+ * Puts the UI pass line in a pull request body and touches nothing else.
59
+ * The marked line is replaced where it stands, else appended to the end of
60
+ * `## Testing`, which is created ahead of `## For the reviewer` when absent.
61
+ */
62
+ export function writeUiPassLine(body: string, summary: UiPassSummary): string {
63
+ const line = uiPassLine(summary)
64
+ const lines = body.split('\n')
65
+
66
+ const marked = lines.findIndex((row) => row.includes(UI_PASS_MARKER))
67
+ if (marked !== -1) {
68
+ lines[marked] = line
69
+ return lines.join('\n')
70
+ }
71
+
72
+ const heading = lines.findIndex((row) => TESTING_HEADING.test(row))
73
+ if (heading === -1) {
74
+ const reviewer = lines.findIndex((row) => REVIEWER_HEADING.test(row))
75
+ if (reviewer === -1) {
76
+ return `${body.trimEnd()}\n\n## Testing\n\n${line}\n`
77
+ }
78
+ lines.splice(reviewer, 0, '## Testing', '', line, '')
79
+ return lines.join('\n')
80
+ }
81
+
82
+ const next = lines.findIndex(
83
+ (row, index) => index > heading && SECTION_HEADING.test(row),
84
+ )
85
+ let last = next === -1 ? lines.length - 1 : next - 1
86
+ while (last > heading && lines[last]?.trim() === '') last -= 1
87
+
88
+ if (last > heading) {
89
+ lines.splice(last + 1, 0, line)
90
+ } else if (lines[heading + 1] === '') {
91
+ lines.splice(heading + 2, 0, line)
92
+ } else {
93
+ lines.splice(heading + 1, 0, '', line)
94
+ }
95
+ return lines.join('\n')
96
+ }
97
+
9
98
  export type TickRefusal = 'no-checklist' | 'no-box' | 'taste-box'
10
99
 
11
100
  export type TickResult =
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync, readlinkSync } from 'node:fs'
2
+ import { createConnection } from 'node:net'
2
3
  import type { SessionRecord } from '@/sessions/registry'
3
4
 
4
5
  /**
@@ -35,6 +36,12 @@ export interface LivenessProbes {
35
36
  readonly ownDomain?: () => string | null
36
37
  /** Whether the messaging socket a foreign record names still exists. */
37
38
  readonly socketExists?: (path: string) => boolean
39
+ /**
40
+ * Whether something accepts a connection on the socket within the timeout.
41
+ * Async, so `liveness()` never calls it and a caller with no use for it, such
42
+ * as the gate's lock, states nothing.
43
+ */
44
+ readonly listens?: (path: string, timeoutMs: number) => Promise<boolean>
38
45
  }
39
46
 
40
47
  /**
@@ -74,11 +81,30 @@ function ownDomain(): string | null {
74
81
  }
75
82
  }
76
83
 
84
+ /**
85
+ * Connects and closes without writing, so the peer sees no bytes. The socket is
86
+ * destroyed on connect rather than waited on, since a listener that accepts and
87
+ * never closes would otherwise hold the probe open past its timeout.
88
+ */
89
+ function listens(path: string, timeoutMs: number): Promise<boolean> {
90
+ return new Promise((resolve) => {
91
+ const socket = createConnection(path)
92
+ const settle = (accepted: boolean): void => {
93
+ socket.destroy()
94
+ resolve(accepted)
95
+ }
96
+ socket.setTimeout(timeoutMs, () => settle(false))
97
+ socket.once('connect', () => settle(true))
98
+ socket.once('error', () => settle(false))
99
+ })
100
+ }
101
+
77
102
  export const SYSTEM_PROBES: LivenessProbes = {
78
103
  procStartOf,
79
104
  responds,
80
105
  ownDomain,
81
106
  socketExists: existsSync,
107
+ listens,
82
108
  }
83
109
 
84
110
  /**
@@ -23,11 +23,18 @@ export interface ResolvedSession {
23
23
  readonly kind: string
24
24
  readonly status: string
25
25
  /**
26
- * What decided the row was live. `socket` means the record belongs to another
27
- * pid namespace and only its messaging socket answered, so the row can be a
28
- * crashed session's leftover and no start time backs it.
26
+ * What decided the row was live. `pid` is a start time matched or a pid that
27
+ * answered, `socket` means the record belongs to another pid namespace and
28
+ * only its messaging socket file existed, so the row can be a crashed
29
+ * session's leftover, and `listener` means the messaging socket accepted a
30
+ * connection, which confirms the row wherever the start time cannot be read.
29
31
  */
30
- readonly via: 'pid' | 'socket'
32
+ readonly via: 'pid' | 'socket' | 'listener'
33
+ /**
34
+ * How this row alone was decided, so a caller scoping the roster derives its
35
+ * confidence over the rows it keeps rather than over the whole machine.
36
+ */
37
+ readonly confidence: Confidence
31
38
  readonly startedAt: string | null
32
39
  /**
33
40
  * The stamp the record carries beside `status`, kept alongside the dwell it
@@ -80,6 +87,22 @@ export type SessionReport =
80
87
  readonly sessions: readonly ResolvedSession[]
81
88
  }
82
89
 
90
+ /**
91
+ * The weakest confidence among the rows, so one read tells a caller whether
92
+ * every row it holds is an identity. A caller scoping the roster calls this on
93
+ * the rows it keeps, since one unrelated row it dropped must not weaken it.
94
+ */
95
+ export function confidenceOf(
96
+ sessions: readonly Pick<ResolvedSession, 'confidence'>[],
97
+ ): Confidence {
98
+ return sessions.every((session) => session.confidence === 'confirmed')
99
+ ? 'confirmed'
100
+ : 'unverified'
101
+ }
102
+
103
+ /** A live socket here accepted in 2 ms, and every probe runs in parallel. */
104
+ const LISTEN_TIMEOUT_MS = 250
105
+
83
106
  export interface ResolveOptions {
84
107
  readonly dir?: string
85
108
  readonly probes?: LivenessProbes
@@ -181,6 +204,7 @@ function present(
181
204
  located: Located,
182
205
  now: number,
183
206
  via: ResolvedSession['via'],
207
+ confidence: Confidence,
184
208
  ): ResolvedSession {
185
209
  return {
186
210
  name: record.name,
@@ -190,6 +214,7 @@ function present(
190
214
  kind: record.kind ?? 'unknown',
191
215
  status: record.status ?? 'unknown',
192
216
  via,
217
+ confidence,
193
218
  startedAt:
194
219
  record.startedAt === undefined
195
220
  ? null
@@ -228,8 +253,11 @@ export async function resolveSessions(
228
253
 
229
254
  if (registry.kind === 'absent') return { kind: 'absent', dir: registry.dir }
230
255
 
231
- const live: { record: SessionRecord; via: ResolvedSession['via'] }[] = []
232
- let confidence: Confidence = 'confirmed'
256
+ const live: {
257
+ record: SessionRecord
258
+ via: ResolvedSession['via']
259
+ confidence: Confidence
260
+ }[] = []
233
261
  let foreignDead = 0
234
262
 
235
263
  for (const record of registry.records) {
@@ -238,20 +266,43 @@ export async function resolveSessions(
238
266
  if (state.foreign) foreignDead += 1
239
267
  continue
240
268
  }
241
- if (state.confidence === 'unverified') confidence = 'unverified'
242
- live.push({ record, via: state.foreign ? 'socket' : 'pid' })
269
+ live.push({
270
+ record,
271
+ via: state.foreign ? 'socket' : 'pid',
272
+ confidence: state.confidence,
273
+ })
243
274
  }
244
275
 
276
+ // Only a row the start time left unverified is probed, so a machine that reads
277
+ // /proc pays nothing. An accept proves the session is running where a pid
278
+ // answering or a socket file existing does not.
279
+ const upgraded = await Promise.all(
280
+ live.map(async (row) => {
281
+ const socket = row.record.messagingSocketPath
282
+ if (
283
+ row.confidence === 'confirmed' ||
284
+ socket === undefined ||
285
+ probes.listens === undefined
286
+ ) {
287
+ return row
288
+ }
289
+ const accepted = await probes.listens(socket, LISTEN_TIMEOUT_MS)
290
+ return accepted
291
+ ? { ...row, via: 'listener' as const, confidence: 'confirmed' as const }
292
+ : row
293
+ }),
294
+ )
295
+
245
296
  const sessions = await Promise.all(
246
- live.map(async ({ record, via }) =>
247
- present(record, await find(record.cwd), now, via),
297
+ upgraded.map(async ({ record, via, confidence }) =>
298
+ present(record, await find(record.cwd), now, via, confidence),
248
299
  ),
249
300
  )
250
301
 
251
302
  return {
252
303
  kind: 'resolved',
253
304
  dir: registry.dir,
254
- confidence,
305
+ confidence: confidenceOf(sessions),
255
306
  foreignDead,
256
307
  sessions,
257
308
  }