@erclx/canon 5.46.0 → 5.48.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 (55) 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/closing-issues.md +2 -0
  6. package/claude/skills/git-pr/references/pr.md +58 -7
  7. package/claude/skills/review-address/SKILL.md +18 -6
  8. package/claude/skills/review-address/references/post-review-findings.md +4 -1
  9. package/claude/skills/review-address/references/rebase-only.md +14 -2
  10. package/claude/skills/review-pr/SKILL.md +9 -2
  11. package/claude/skills/review-pr/references/body-variants.md +29 -8
  12. package/claude/skills/review-pr/references/post.md +29 -10
  13. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  14. package/claude/skills/review-ui/SKILL.md +4 -3
  15. package/claude/skills/review-ui/references/post.md +16 -2
  16. package/claude/skills/review-ui/references/tick.md +7 -1
  17. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +4 -2
  19. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  20. package/claude/skills/role-reviewer/SKILL.md +4 -1
  21. package/claude/skills/role-worker/REQUIREMENT.md +1 -1
  22. package/claude/skills/role-worker/SKILL.md +1 -0
  23. package/claude/skills/task-board/references/decline.md +2 -2
  24. package/claude/skills/task-board/references/tasks.md +4 -2
  25. package/claude/skills/task-complete/SKILL.md +1 -0
  26. package/claude/skills/task-complete/references/archive.md +5 -3
  27. package/claude/skills/ui-checklist/SKILL.md +3 -3
  28. package/docs/agents/migrate.md +2 -2
  29. package/docs/target/migrations.md +4 -2
  30. package/docs/workflow/operating-model.md +2 -2
  31. package/docs/workflow/zshrc-aliases.md +3 -0
  32. package/package.json +1 -1
  33. package/scripts/core/bootstrap.sh +1 -0
  34. package/src/commands/claude.ts +4 -0
  35. package/src/commands/labels.ts +66 -1
  36. package/src/commands/migrate.ts +5 -4
  37. package/src/commands/pr.ts +405 -30
  38. package/src/commands/sessions.ts +25 -11
  39. package/src/commands/tasks.ts +11 -1
  40. package/src/labels/density.ts +363 -0
  41. package/src/migrate/session-defaults.ts +20 -5
  42. package/src/pr/evidence.ts +1 -1
  43. package/src/pr/lift.ts +19 -0
  44. package/src/pr/merge.ts +175 -0
  45. package/src/pr/rest.ts +3 -0
  46. package/src/pr/review-scope.ts +40 -1
  47. package/src/pr/tick.ts +109 -0
  48. package/src/sessions/live.ts +26 -0
  49. package/src/sessions/resolve.ts +62 -11
  50. package/src/tasks/archive.ts +32 -8
  51. package/src/tasks/closes.ts +9 -5
  52. package/src/tasks/validate.ts +4 -2
  53. package/tooling/base/seeds/.github/pull_request_template.md +1 -1
  54. package/tooling/claude/reference.md +3 -1
  55. package/tooling/claude/seeds/.claude/settings.json +1 -1
@@ -0,0 +1,175 @@
1
+ import type { ChecksGate } from '@/pr/lift'
2
+ import { readSection } from '@/pr/paths'
3
+ import { UI_PASS_MARKER } from '@/pr/tick'
4
+
5
+ /** Why the verb did not merge, in the order the refusals are read. */
6
+ export type MergeRefusal =
7
+ | 'pull-closed'
8
+ | 'pull-draft'
9
+ | 'missing-summary'
10
+ | 'missing-key-changes'
11
+ | 'ci-failing'
12
+ | 'ci-conflicted'
13
+ | 'ci-pending'
14
+ | 'head-moved'
15
+ | 'not-mergeable'
16
+ | 'gh-failed'
17
+
18
+ export interface MergePull {
19
+ readonly isOpen: boolean
20
+ readonly isDraft: boolean
21
+ readonly title: string
22
+ readonly body: string
23
+ /** The head the merge is pinned to, so a push after the read refuses. */
24
+ readonly head: string
25
+ }
26
+
27
+ export interface MergeRequest {
28
+ readonly title: string
29
+ readonly description: string
30
+ readonly sha: string
31
+ }
32
+
33
+ /** What the merge endpoint answered, with 409 and 405 told apart from a merge. */
34
+ export type MergeOutcome =
35
+ | { readonly kind: 'merged'; readonly sha: string }
36
+ | { readonly kind: 'head-moved' }
37
+ | { readonly kind: 'not-mergeable' }
38
+ | { readonly kind: 'failed' }
39
+
40
+ /**
41
+ * Everything the verb reads or writes, injected so every refusal runs without
42
+ * a network. A read that failed returns undefined, and the verb refuses
43
+ * `gh-failed` rather than reading absence as a pass.
44
+ */
45
+ export interface MergeApi {
46
+ readPull(): Promise<MergePull | undefined>
47
+ readChecks(head: string): Promise<ChecksGate | undefined>
48
+ putMerge(request: MergeRequest): Promise<MergeOutcome>
49
+ }
50
+
51
+ export interface MergeOptions {
52
+ readonly number: number
53
+ readonly isDryRun: boolean
54
+ }
55
+
56
+ export interface MergeRecord {
57
+ readonly reason: 'merged' | 'dry-run' | MergeRefusal
58
+ readonly isOk: boolean
59
+ readonly number: number
60
+ readonly title?: string
61
+ readonly description?: string
62
+ /** The squash commit, present only on a merge. */
63
+ readonly sha?: string
64
+ }
65
+
66
+ export type Composed =
67
+ | { readonly kind: 'composed'; readonly description: string }
68
+ | {
69
+ readonly kind: 'refused'
70
+ readonly reason: 'missing-summary' | 'missing-key-changes'
71
+ }
72
+
73
+ /** A section's text with the UI pass line dropped, or undefined when nothing is left. */
74
+ function keptSection(body: string, title: string): string | undefined {
75
+ const text = readSection(body, title)
76
+ ?.split('\n')
77
+ .filter((line) => !line.includes(UI_PASS_MARKER))
78
+ .join('\n')
79
+ .trim()
80
+ return text === undefined || text === ''
81
+ ? undefined
82
+ : `## ${title}\n\n${text}`
83
+ }
84
+
85
+ /**
86
+ * The squash description: Summary, Key changes, and Technical context, in that
87
+ * order, under their headings. Testing, the reviewer requests, and the UI pass
88
+ * line describe the review rather than the change, so none reaches the trunk.
89
+ * Technical context is optional, and an empty one leaves no bare heading.
90
+ */
91
+ export function composeDescription(body: string): Composed {
92
+ const summary = keptSection(body, 'Summary')
93
+ if (summary === undefined)
94
+ return { kind: 'refused', reason: 'missing-summary' }
95
+ const keyChanges = keptSection(body, 'Key changes')
96
+ if (keyChanges === undefined) {
97
+ return { kind: 'refused', reason: 'missing-key-changes' }
98
+ }
99
+ const context = keptSection(body, 'Technical context')
100
+ return {
101
+ kind: 'composed',
102
+ description: [summary, keyChanges, context]
103
+ .filter((block) => block !== undefined)
104
+ .join('\n\n'),
105
+ }
106
+ }
107
+
108
+ /** The suffix the web merge adds and the trunk log carries. */
109
+ export function mergeTitle(title: string, number: number): string {
110
+ return `${title} (#${number})`
111
+ }
112
+
113
+ function refused(
114
+ reason: MergeRefusal,
115
+ number: number,
116
+ message: { title?: string; description?: string } = {},
117
+ ): MergeRecord {
118
+ return { reason, isOk: false, number, ...message }
119
+ }
120
+
121
+ /**
122
+ * Squash-merges one ready pull request with a message carrying only the
123
+ * change. Every refusal is read before the one write, and the write sends the
124
+ * head it read as `sha`, so GitHub refuses rather than merging a commit pushed
125
+ * after the checks were read. The verb reads the draft flag and not the review
126
+ * comments, since a ready pull request already passed the lift's gates.
127
+ */
128
+ export async function merge(
129
+ api: MergeApi,
130
+ opts: MergeOptions,
131
+ ): Promise<MergeRecord> {
132
+ const { number } = opts
133
+
134
+ const pull = await api.readPull()
135
+ if (pull === undefined) return refused('gh-failed', number)
136
+ if (!pull.isOpen) return refused('pull-closed', number)
137
+ if (pull.isDraft) return refused('pull-draft', number)
138
+
139
+ const title = mergeTitle(pull.title, number)
140
+ const composed = composeDescription(pull.body)
141
+ if (composed.kind === 'refused') {
142
+ return refused(composed.reason, number, { title })
143
+ }
144
+ const message = { title, description: composed.description }
145
+
146
+ const checks = await api.readChecks(pull.head)
147
+ if (checks === undefined) return refused('gh-failed', number, message)
148
+ if (checks.state === 'failing') return refused('ci-failing', number, message)
149
+ if (checks.state !== 'passing') {
150
+ return refused(
151
+ checks.isConflicted ? 'ci-conflicted' : 'ci-pending',
152
+ number,
153
+ message,
154
+ )
155
+ }
156
+
157
+ if (opts.isDryRun)
158
+ return { reason: 'dry-run', isOk: true, number, ...message }
159
+
160
+ const outcome = await api.putMerge({ ...message, sha: pull.head })
161
+ if (outcome.kind === 'merged') {
162
+ return {
163
+ reason: 'merged',
164
+ isOk: true,
165
+ number,
166
+ ...message,
167
+ sha: outcome.sha,
168
+ }
169
+ }
170
+ return refused(
171
+ outcome.kind === 'failed' ? 'gh-failed' : outcome.kind,
172
+ number,
173
+ message,
174
+ )
175
+ }
package/src/pr/rest.ts CHANGED
@@ -13,6 +13,7 @@ import type { ReviewRow } from '@/pr/review-scope'
13
13
  /** One row of `repos/{owner}/{repo}/pulls/<n>` or of the pulls listing. */
14
14
  export interface RestPull {
15
15
  readonly number?: number
16
+ readonly title?: string
16
17
  readonly body?: string | null
17
18
  readonly head?: { readonly ref?: string; readonly sha?: string }
18
19
  readonly base?: { readonly ref?: string }
@@ -42,6 +43,7 @@ export interface PullIdentity {
42
43
  readonly head?: string
43
44
  readonly base?: string
44
45
  readonly mergeState?: string
46
+ readonly body?: string
45
47
  }
46
48
 
47
49
  export type BranchPull =
@@ -70,6 +72,7 @@ export function identityOf(row: RestPull): PullIdentity {
70
72
  ...(row.head?.sha !== undefined && { head: row.head.sha }),
71
73
  ...(row.base?.ref !== undefined && { base: row.base.ref }),
72
74
  ...(mergeState !== undefined && { mergeState }),
75
+ ...(typeof row.body === 'string' && { body: row.body }),
73
76
  }
74
77
  }
75
78
 
@@ -136,11 +136,21 @@ export const UI_HEADINGS = ['## UI review', '## UI review closed'] as const
136
136
 
137
137
  export type UiHeading = (typeof UI_HEADINGS)[number]
138
138
 
139
+ /** The four totals on a verdict's count line. */
140
+ export interface UiCounts {
141
+ readonly failed: number
142
+ readonly eyes: number
143
+ readonly passed: number
144
+ readonly notDriven: number
145
+ }
146
+
139
147
  export interface UiScope {
140
148
  readonly heading?: UiHeading
141
149
  readonly state: ReviewState
142
150
  /** The head the verdict covered, a full or abbreviated sha. */
143
151
  readonly commit?: string
152
+ /** The counts the verdict wrote, absent when its count line does not parse. */
153
+ readonly counts?: UiCounts
144
154
  /** `marker` is the verdict's own record, `fallback` is GitHub's stamp. */
145
155
  readonly source: ScopeSource
146
156
  }
@@ -152,6 +162,32 @@ export interface UiScope {
152
162
  */
153
163
  const UI_MARKER = /^<!--\s*review-ui:\s*head=([0-9a-f]{7,40})\s*-->$/
154
164
 
165
+ /**
166
+ * The count line `review-ui`'s `post.md` fixes as the verdict's first line
167
+ * under the heading. It is model-written prose, so a body that drifts from the
168
+ * form reads as no counts and a caller treats that as unchecked.
169
+ */
170
+ const UI_COUNTS =
171
+ /^(\d+) failed · (\d+) need your eyes · (\d+) passed · (\d+) not driven$/
172
+
173
+ export function readUiCounts(body: string): UiCounts | undefined {
174
+ for (const raw of body.split('\n').slice(1, 6)) {
175
+ const match = UI_COUNTS.exec(raw.trim())
176
+ if (match === null) continue
177
+ const [, failed, eyes, passed, notDriven] = match.map(Number)
178
+ if (
179
+ failed === undefined ||
180
+ eyes === undefined ||
181
+ passed === undefined ||
182
+ notDriven === undefined
183
+ ) {
184
+ return undefined
185
+ }
186
+ return { failed, eyes, passed, notDriven }
187
+ }
188
+ return undefined
189
+ }
190
+
155
191
  function lastLine(body: string): string | undefined {
156
192
  const lines = body.split('\n')
157
193
 
@@ -185,9 +221,11 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
185
221
  const line = lastLine(row.body ?? '')
186
222
  const match = line === undefined ? null : UI_MARKER.exec(line)
187
223
  const marked = match?.[1]
224
+ const counts = readUiCounts(row.body ?? '')
225
+ const counted = counts === undefined ? {} : { counts }
188
226
 
189
227
  if (marked !== undefined) {
190
- return { heading, state, commit: marked, source: 'marker' }
228
+ return { heading, state, commit: marked, ...counted, source: 'marker' }
191
229
  }
192
230
 
193
231
  const oid = row.commit?.oid
@@ -195,6 +233,7 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
195
233
  heading,
196
234
  state,
197
235
  ...(oid === undefined || oid === '' ? {} : { commit: oid }),
236
+ ...counted,
198
237
  source: 'fallback',
199
238
  }
200
239
  }
package/src/pr/tick.ts CHANGED
@@ -1,11 +1,120 @@
1
1
  import {
2
+ type ChecklistBox,
2
3
  CHECKLIST_END,
3
4
  CHECKLIST_START,
4
5
  parseBoxLine,
5
6
  readChecklistBoxes,
7
+ SHORT_SHA_LENGTH,
6
8
  writeBoxLine,
7
9
  } from '@/pr/evidence'
8
10
 
11
+ export const UI_PASS_MARKER = '<!-- pr-ui-pass -->'
12
+ const TESTING_HEADING = /^## Testing\s*$/
13
+ const SECTION_HEADING = /^## /
14
+ const REVIEWER_HEADING = /^## For the reviewer\s*$/
15
+ const UI_PASS_HEAD = /at `([0-9a-f]+)`/
16
+
17
+ /** What the UI pass line reports, either a pass or a reset to a head nobody has driven. */
18
+ export type UiPassSummary =
19
+ | {
20
+ readonly kind: 'passed'
21
+ /** Driven boxes ticked, taste boxes excluded. */
22
+ readonly passed: number
23
+ /** Boxes a driver can run, taste boxes excluded. */
24
+ readonly total: number
25
+ readonly taste: number
26
+ readonly head: string
27
+ }
28
+ | { readonly kind: 'reset'; readonly head: string }
29
+
30
+ /**
31
+ * The one `## Testing` line a UI pass owns. It is ticked only when every box
32
+ * a driver can run passed, so a failed box never reads as done, and taste
33
+ * boxes are counted apart so a full pass never reads as one box failed.
34
+ */
35
+ export function uiPassLine(summary: UiPassSummary): string {
36
+ const at = `\`${summary.head.slice(0, SHORT_SHA_LENGTH)}\``
37
+ if (summary.kind === 'reset') {
38
+ return `- [ ] UI pass, not run at ${at} ${UI_PASS_MARKER}`
39
+ }
40
+ const taste =
41
+ summary.taste === 0
42
+ ? ''
43
+ : `${summary.taste} taste ${summary.taste === 1 ? 'box' : 'boxes'} left to the operator`
44
+ if (summary.total === 0) {
45
+ return `- [ ] UI pass, no box a driver can run, ${taste} at ${at} ${UI_PASS_MARKER}`
46
+ }
47
+ const box = summary.passed === summary.total ? 'x' : ' '
48
+ const tail = taste === '' ? '' : `, ${taste}`
49
+ return `- [${box}] UI pass, ${summary.passed} of ${summary.total} boxes passed at ${at}${tail} ${UI_PASS_MARKER}`
50
+ }
51
+
52
+ /**
53
+ * The driven boxes ticked at the head. A tick counts as this pass's only when
54
+ * stamped at the head, since an unstamped one is the author's claim and an
55
+ * older stamp names another head. Taste boxes are left out, as the body line
56
+ * leaves them out.
57
+ */
58
+ export function countTicksAtHead(
59
+ boxes: readonly ChecklistBox[],
60
+ head: string,
61
+ ): number {
62
+ return boxes.filter(
63
+ (box) =>
64
+ !box.isTaste &&
65
+ box.isTicked &&
66
+ box.stamp !== undefined &&
67
+ head.startsWith(box.stamp),
68
+ ).length
69
+ }
70
+
71
+ /** The short sha the marked line names, absent when the body carries no such line. */
72
+ export function readUiPassHead(body: string): string | undefined {
73
+ const line = body.split('\n').find((row) => row.includes(UI_PASS_MARKER))
74
+ return line === undefined ? undefined : UI_PASS_HEAD.exec(line)?.[1]
75
+ }
76
+
77
+ /**
78
+ * Puts the UI pass line in a pull request body and touches nothing else.
79
+ * The marked line is replaced where it stands, else appended to the end of
80
+ * `## Testing`, which is created ahead of `## For the reviewer` when absent.
81
+ */
82
+ export function writeUiPassLine(body: string, summary: UiPassSummary): string {
83
+ const line = uiPassLine(summary)
84
+ const lines = body.split('\n')
85
+
86
+ const marked = lines.findIndex((row) => row.includes(UI_PASS_MARKER))
87
+ if (marked !== -1) {
88
+ lines[marked] = line
89
+ return lines.join('\n')
90
+ }
91
+
92
+ const heading = lines.findIndex((row) => TESTING_HEADING.test(row))
93
+ if (heading === -1) {
94
+ const reviewer = lines.findIndex((row) => REVIEWER_HEADING.test(row))
95
+ if (reviewer === -1) {
96
+ return `${body.trimEnd()}\n\n## Testing\n\n${line}\n`
97
+ }
98
+ lines.splice(reviewer, 0, '## Testing', '', line, '')
99
+ return lines.join('\n')
100
+ }
101
+
102
+ const next = lines.findIndex(
103
+ (row, index) => index > heading && SECTION_HEADING.test(row),
104
+ )
105
+ let last = next === -1 ? lines.length - 1 : next - 1
106
+ while (last > heading && lines[last]?.trim() === '') last -= 1
107
+
108
+ if (last > heading) {
109
+ lines.splice(last + 1, 0, line)
110
+ } else if (lines[heading + 1] === '') {
111
+ lines.splice(heading + 2, 0, line)
112
+ } else {
113
+ lines.splice(heading + 1, 0, '', line)
114
+ }
115
+ return lines.join('\n')
116
+ }
117
+
9
118
  export type TickRefusal = 'no-checklist' | 'no-box' | 'taste-box'
10
119
 
11
120
  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
  }