@reconcrap/boss-recommend-mcp 2.1.23 → 2.1.25

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 (67) hide show
  1. package/README.md +5 -0
  2. package/bin/boss-recommend-mcp.js +4 -4
  3. package/package.json +6 -1
  4. package/scripts/install-macos.sh +280 -280
  5. package/scripts/postinstall.cjs +44 -44
  6. package/skills/boss-chat/README.md +43 -42
  7. package/skills/boss-chat/SKILL.md +107 -106
  8. package/skills/boss-recommend-pipeline/README.md +13 -13
  9. package/skills/boss-recommend-pipeline/SKILL.md +47 -47
  10. package/skills/boss-recruit-pipeline/README.md +19 -19
  11. package/skills/boss-recruit-pipeline/SKILL.md +89 -89
  12. package/src/chat-mcp.js +301 -127
  13. package/src/chat-runtime-config.js +7 -5
  14. package/src/core/boss-cards/index.js +199 -199
  15. package/src/core/browser/index.js +302 -145
  16. package/src/core/capture/index.js +2930 -1201
  17. package/src/core/cv-acquisition/index.js +512 -238
  18. package/src/core/cv-capture-target/index.js +513 -299
  19. package/src/core/greet-quota/index.js +71 -71
  20. package/src/core/infinite-list/index.js +31 -31
  21. package/src/core/reporting/legacy-csv.js +12 -12
  22. package/src/core/run/detached-launcher.js +305 -0
  23. package/src/core/run/index.js +109 -55
  24. package/src/core/run/timing.js +33 -33
  25. package/src/core/run/windows-detached-worker.ps1 +106 -0
  26. package/src/core/screening/index.js +2400 -2135
  27. package/src/core/self-heal/index.js +989 -973
  28. package/src/core/self-heal/viewport.js +1505 -564
  29. package/src/detached-worker.js +99 -99
  30. package/src/domains/chat/action-journal.js +536 -0
  31. package/src/domains/chat/cards.js +137 -137
  32. package/src/domains/chat/constants.js +9 -9
  33. package/src/domains/chat/detail.js +1684 -401
  34. package/src/domains/chat/index.js +8 -7
  35. package/src/domains/chat/jobs.js +620 -620
  36. package/src/domains/chat/page-guard.js +157 -122
  37. package/src/domains/chat/roots.js +56 -56
  38. package/src/domains/chat/run-service.js +1801 -760
  39. package/src/domains/common/account-rights-panel.js +314 -314
  40. package/src/domains/common/recovery-settle.js +159 -159
  41. package/src/domains/recommend/actions.js +1219 -472
  42. package/src/domains/recommend/cards.js +243 -243
  43. package/src/domains/recommend/colleague-contact.js +1079 -333
  44. package/src/domains/recommend/constants.js +92 -92
  45. package/src/domains/recommend/detail.js +4037 -136
  46. package/src/domains/recommend/filters.js +608 -590
  47. package/src/domains/recommend/index.js +9 -9
  48. package/src/domains/recommend/jobs.js +571 -542
  49. package/src/domains/recommend/location.js +754 -707
  50. package/src/domains/recommend/refresh.js +677 -392
  51. package/src/domains/recommend/roots.js +80 -80
  52. package/src/domains/recommend/run-service.js +4048 -1447
  53. package/src/domains/recommend/scopes.js +246 -246
  54. package/src/domains/recruit/actions.js +277 -277
  55. package/src/domains/recruit/cards.js +74 -74
  56. package/src/domains/recruit/constants.js +236 -236
  57. package/src/domains/recruit/detail.js +588 -588
  58. package/src/domains/recruit/index.js +9 -9
  59. package/src/domains/recruit/instruction-parser.js +866 -866
  60. package/src/domains/recruit/refresh.js +45 -45
  61. package/src/domains/recruit/roots.js +68 -68
  62. package/src/domains/recruit/run-service.js +1817 -1620
  63. package/src/domains/recruit/search.js +3229 -3229
  64. package/src/index.js +16 -1
  65. package/src/parser.js +1296 -1296
  66. package/src/recommend-mcp.js +1061 -450
  67. package/src/recommend-scheduler.js +75 -75
@@ -1,333 +1,1079 @@
1
- import {
2
- clickNodeCenter,
3
- getNodeBox,
4
- getOuterHTML,
5
- querySelectorAll,
6
- sleep
7
- } from "../../core/browser/index.js";
8
- import { htmlToText } from "../../core/screening/index.js";
9
-
10
- const COLLEAGUE_SECTION_SELECTOR = ".colleague-collaboration";
11
- const COLLEAGUE_TAB_SELECTOR = ".colleague-collaboration .tab-hd";
12
- const SELECTED_TAB_SELECTOR = ".colleague-collaboration .tab-hd .selected";
13
- const SECTION_SELECTED_TAB_SELECTOR = ".tab-hd .selected";
14
- const TAB_CANDIDATE_SELECTOR = ".tab-hd span, .tab-hd div, .tab-hd *";
15
- const ROW_SELECTOR = ".colleague-collaboration .record-item.mate-log-item";
16
- const ROW_CONTENT_SELECTOR = ".colleague-collaboration .record-item.mate-log-item .content";
17
- const DETAIL_PANE_SELECTOR = ".resume-item-detail";
18
-
19
- function normalizeText(value) {
20
- return String(value || "").replace(/\s+/g, " ").trim();
21
- }
22
-
23
- function dateOnly(value) {
24
- const date = value instanceof Date ? value : new Date(value);
25
- if (Number.isNaN(date.getTime())) return null;
26
- return new Date(date.getFullYear(), date.getMonth(), date.getDate());
27
- }
28
-
29
- function daysBetween(left, right) {
30
- const leftDate = dateOnly(left);
31
- const rightDate = dateOnly(right);
32
- if (!leftDate || !rightDate) return null;
33
- return Math.floor((leftDate.getTime() - rightDate.getTime()) / 86400000);
34
- }
35
-
36
- function formatLocalDate(date) {
37
- const parsed = dateOnly(date);
38
- if (!parsed) return null;
39
- const year = parsed.getFullYear();
40
- const month = String(parsed.getMonth() + 1).padStart(2, "0");
41
- const day = String(parsed.getDate()).padStart(2, "0");
42
- return `${year}-${month}-${day}`;
43
- }
44
-
45
- function makeDate(year, month, day) {
46
- const parsed = new Date(year, month - 1, day);
47
- if (
48
- parsed.getFullYear() !== year
49
- || parsed.getMonth() !== month - 1
50
- || parsed.getDate() !== day
51
- ) {
52
- return null;
53
- }
54
- return parsed;
55
- }
56
-
57
- export function parseColleagueContactDate(text, {
58
- referenceDate = new Date()
59
- } = {}) {
60
- const raw = normalizeText(text);
61
- if (!raw) return null;
62
- const today = dateOnly(referenceDate) || dateOnly(new Date());
63
- const relativeDays = raw.match(/(\d+)\s*天前/);
64
- if (relativeDays) {
65
- const days = Number.parseInt(relativeDays[1], 10);
66
- if (Number.isFinite(days) && days >= 0) {
67
- const date = new Date(today);
68
- date.setDate(date.getDate() - days);
69
- return date;
70
- }
71
- }
72
- if (/今天/.test(raw)) return today;
73
- if (/昨天/.test(raw)) {
74
- const date = new Date(today);
75
- date.setDate(date.getDate() - 1);
76
- return date;
77
- }
78
- if (/前天/.test(raw)) {
79
- const date = new Date(today);
80
- date.setDate(date.getDate() - 2);
81
- return date;
82
- }
83
-
84
- const full = raw.match(/(20\d{2})[.\-\/](\d{1,2})[.\-\/](\d{1,2})/);
85
- if (full) {
86
- return makeDate(
87
- Number.parseInt(full[1], 10),
88
- Number.parseInt(full[2], 10),
89
- Number.parseInt(full[3], 10)
90
- );
91
- }
92
-
93
- const partial = raw.match(/(?:^|\D)(\d{1,2})[.\-\/](\d{1,2})(?:\D|$)/);
94
- if (partial) {
95
- const reference = dateOnly(referenceDate) || new Date();
96
- let date = makeDate(
97
- reference.getFullYear(),
98
- Number.parseInt(partial[1], 10),
99
- Number.parseInt(partial[2], 10)
100
- );
101
- if (date && daysBetween(date, reference) > 7) {
102
- date = makeDate(
103
- reference.getFullYear() - 1,
104
- Number.parseInt(partial[1], 10),
105
- Number.parseInt(partial[2], 10)
106
- );
107
- }
108
- return date;
109
- }
110
-
111
- return null;
112
- }
113
-
114
- export function isDateWithinWindow(date, {
115
- referenceDate = new Date(),
116
- windowDays = 14
117
- } = {}) {
118
- const diff = daysBetween(referenceDate, date);
119
- return Number.isFinite(diff) && diff >= 0 && diff <= windowDays;
120
- }
121
-
122
- async function textForNode(client, nodeId) {
123
- return htmlToText(await getOuterHTML(client, nodeId));
124
- }
125
-
126
- async function queryAcrossRoots(client, roots, selector) {
127
- const matches = [];
128
- for (const root of roots || []) {
129
- if (!root?.nodeId) continue;
130
- const nodeIds = await querySelectorAll(client, root.nodeId, selector).catch(() => []);
131
- for (const nodeId of nodeIds) {
132
- matches.push({
133
- root: root.name,
134
- root_node_id: root.nodeId,
135
- selector,
136
- node_id: nodeId
137
- });
138
- }
139
- }
140
- return matches;
141
- }
142
-
143
- function tabIsColleague(text) {
144
- return /同事沟通进度/.test(normalizeText(text));
145
- }
146
-
147
- async function ensureColleagueTabSelected(client, sectionNodeId) {
148
- const selectedIds = await querySelectorAll(client, sectionNodeId, SECTION_SELECTED_TAB_SELECTOR).catch(() => []);
149
- for (const nodeId of selectedIds) {
150
- const text = normalizeText(await textForNode(client, nodeId).catch(() => ""));
151
- if (tabIsColleague(text)) {
152
- return {
153
- selected: true,
154
- changed: false,
155
- selected_text: text
156
- };
157
- }
158
- }
159
-
160
- const candidateIds = await querySelectorAll(client, sectionNodeId, TAB_CANDIDATE_SELECTOR).catch(() => []);
161
- for (const nodeId of candidateIds) {
162
- const text = normalizeText(await textForNode(client, nodeId).catch(() => ""));
163
- if (!tabIsColleague(text)) continue;
164
- const box = await clickNodeCenter(client, nodeId, { scrollIntoView: true });
165
- await sleep(500);
166
- return {
167
- selected: true,
168
- changed: true,
169
- selected_text: text,
170
- click_box: {
171
- rect: box.rect,
172
- center: box.center
173
- }
174
- };
175
- }
176
-
177
- return {
178
- selected: false,
179
- changed: false,
180
- selected_text: selectedIds.length
181
- ? normalizeText(await textForNode(client, selectedIds[0]).catch(() => ""))
182
- : ""
183
- };
184
- }
185
-
186
- async function readContactRows(client, roots) {
187
- const rowMatches = await queryAcrossRoots(client, roots, ROW_CONTENT_SELECTOR);
188
- const fallbackMatches = rowMatches.length ? [] : await queryAcrossRoots(client, roots, ROW_SELECTOR);
189
- const matches = rowMatches.length ? rowMatches : fallbackMatches;
190
- const rows = [];
191
- const seen = new Set();
192
- for (const match of matches) {
193
- const text = normalizeText(await textForNode(client, match.node_id).catch(() => ""));
194
- if (!text || seen.has(text)) continue;
195
- seen.add(text);
196
- rows.push({
197
- text,
198
- root: match.root,
199
- selector: match.selector,
200
- node_id: match.node_id
201
- });
202
- }
203
- return rows;
204
- }
205
-
206
- async function scrollDetailPaneForRows(client, roots, sectionNodeId, {
207
- maxScrolls = 4,
208
- settleMs = 350
209
- } = {}) {
210
- const detailPanes = await queryAcrossRoots(client, roots, DETAIL_PANE_SELECTOR);
211
- const targetNodeId = detailPanes[0]?.node_id || sectionNodeId;
212
- let box = null;
213
- try {
214
- box = await getNodeBox(client, targetNodeId);
215
- } catch {
216
- try {
217
- box = await getNodeBox(client, sectionNodeId);
218
- } catch {
219
- return { scrolls: 0, reason: "scroll_target_box_unavailable" };
220
- }
221
- }
222
- let scrolls = 0;
223
- for (let index = 0; index < maxScrolls; index += 1) {
224
- await client.Input.dispatchMouseEvent({
225
- type: "mouseWheel",
226
- x: box.center.x,
227
- y: box.center.y,
228
- deltaY: 680,
229
- deltaX: 0
230
- });
231
- scrolls += 1;
232
- await sleep(settleMs);
233
- }
234
- return {
235
- scrolls,
236
- target_selector: detailPanes[0]?.selector || COLLEAGUE_SECTION_SELECTOR
237
- };
238
- }
239
-
240
- async function waitForColleagueSections(client, roots, {
241
- timeoutMs = 1000,
242
- intervalMs = 150
243
- } = {}) {
244
- const started = Date.now();
245
- let sections = [];
246
- do {
247
- sections = await queryAcrossRoots(client, roots, COLLEAGUE_SECTION_SELECTOR);
248
- if (sections.length) return sections;
249
- if (Date.now() - started >= timeoutMs) break;
250
- await sleep(intervalMs);
251
- } while (Date.now() - started <= timeoutMs);
252
- return sections;
253
- }
254
-
255
- export async function inspectRecentColleagueContact(client, detailState, {
256
- referenceDate = new Date(),
257
- windowDays = 14,
258
- scroll = true,
259
- sectionWaitMs = 1000,
260
- sectionPollMs = 150
261
- } = {}) {
262
- const roots = detailState?.roots || [];
263
- const sections = await waitForColleagueSections(client, roots, {
264
- timeoutMs: sectionWaitMs,
265
- intervalMs: sectionPollMs
266
- });
267
- if (!sections.length) {
268
- return {
269
- checked: true,
270
- panel_found: false,
271
- recent: false,
272
- reason: "panel_missing",
273
- window_days: windowDays,
274
- rows: []
275
- };
276
- }
277
-
278
- const section = sections[0];
279
- const tabHeader = await queryAcrossRoots(client, roots, COLLEAGUE_TAB_SELECTOR);
280
- const tab = await ensureColleagueTabSelected(client, section.node_id);
281
- if (!tab.selected) {
282
- return {
283
- checked: true,
284
- panel_found: true,
285
- recent: false,
286
- reason: "colleague_tab_unavailable",
287
- window_days: windowDays,
288
- section_root: section.root,
289
- tab_header_found: tabHeader.length > 0,
290
- selected_tab_text: tab.selected_text,
291
- rows: []
292
- };
293
- }
294
-
295
- let rows = await readContactRows(client, roots);
296
- let scroll_probe = null;
297
- if (scroll) {
298
- scroll_probe = await scrollDetailPaneForRows(client, roots, section.node_id);
299
- const afterScrollRows = await readContactRows(client, roots);
300
- const byText = new Map(rows.map((row) => [row.text, row]));
301
- for (const row of afterScrollRows) {
302
- if (!byText.has(row.text)) byText.set(row.text, row);
303
- }
304
- rows = Array.from(byText.values());
305
- }
306
-
307
- const parsedRows = rows.map((row) => {
308
- const parsedDate = parseColleagueContactDate(row.text, { referenceDate });
309
- return {
310
- ...row,
311
- parsed_date: parsedDate ? formatLocalDate(parsedDate) : null,
312
- within_window: parsedDate
313
- ? isDateWithinWindow(parsedDate, { referenceDate, windowDays })
314
- : false
315
- };
316
- });
317
- const matched = parsedRows.find((row) => row.within_window) || null;
318
- return {
319
- checked: true,
320
- panel_found: true,
321
- recent: Boolean(matched),
322
- reason: matched ? "recent_colleague_contact_found" : "no_recent_colleague_contact",
323
- window_days: windowDays,
324
- section_root: section.root,
325
- tab_header_found: tabHeader.length > 0,
326
- selected_tab_text: tab.selected_text,
327
- tab_changed: tab.changed,
328
- matched_row: matched,
329
- row_count: parsedRows.length,
330
- rows: parsedRows,
331
- scroll_probe
332
- };
333
- }
1
+ import {
2
+ clickNodeCenter,
3
+ describeNode,
4
+ getFrameDocumentNodeId,
5
+ getNodeBox,
6
+ getOuterHTML,
7
+ querySelectorAll,
8
+ sleep
9
+ } from "../../core/browser/index.js";
10
+ import { htmlToText } from "../../core/screening/index.js";
11
+
12
+ const COLLEAGUE_SECTION_SELECTOR = ".colleague-collaboration";
13
+ const SECTION_SELECTED_TAB_SELECTOR = ".tab-hd .selected";
14
+ const TAB_CANDIDATE_SELECTOR = ".tab-hd > span, .tab-hd > div, .tab-hd > button, .tab-hd > li";
15
+ const SECTION_ROW_SELECTOR = ".record-item.mate-log-item";
16
+ const SECTION_ROW_CONTENT_SELECTOR = ".record-item.mate-log-item .content";
17
+ const DETAIL_PANE_SELECTOR = ".resume-item-detail";
18
+
19
+ function normalizeText(value) {
20
+ return String(value || "").replace(/\s+/g, " ").trim();
21
+ }
22
+
23
+ function dateOnly(value) {
24
+ const date = value instanceof Date ? value : new Date(value);
25
+ if (Number.isNaN(date.getTime())) return null;
26
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
27
+ }
28
+
29
+ function daysBetween(left, right) {
30
+ const leftDate = dateOnly(left);
31
+ const rightDate = dateOnly(right);
32
+ if (!leftDate || !rightDate) return null;
33
+ return Math.floor((leftDate.getTime() - rightDate.getTime()) / 86400000);
34
+ }
35
+
36
+ function formatLocalDate(date) {
37
+ const parsed = dateOnly(date);
38
+ if (!parsed) return null;
39
+ const year = parsed.getFullYear();
40
+ const month = String(parsed.getMonth() + 1).padStart(2, "0");
41
+ const day = String(parsed.getDate()).padStart(2, "0");
42
+ return `${year}-${month}-${day}`;
43
+ }
44
+
45
+ function makeDate(year, month, day) {
46
+ const parsed = new Date(year, month - 1, day);
47
+ if (
48
+ parsed.getFullYear() !== year
49
+ || parsed.getMonth() !== month - 1
50
+ || parsed.getDate() !== day
51
+ ) {
52
+ return null;
53
+ }
54
+ return parsed;
55
+ }
56
+
57
+ export function parseColleagueContactDate(text, {
58
+ referenceDate = new Date()
59
+ } = {}) {
60
+ const raw = normalizeText(text);
61
+ if (!raw) return null;
62
+ const today = dateOnly(referenceDate) || dateOnly(new Date());
63
+ if (/(?:刚刚|刚才)/.test(raw)) return today;
64
+ const relativeDays = raw.match(/(\d+)\s*天前/);
65
+ if (relativeDays) {
66
+ const days = Number.parseInt(relativeDays[1], 10);
67
+ if (Number.isFinite(days) && days >= 0) {
68
+ const date = new Date(today);
69
+ date.setDate(date.getDate() - days);
70
+ return date;
71
+ }
72
+ }
73
+ if (/今天/.test(raw)) return today;
74
+ if (/昨天/.test(raw)) {
75
+ const date = new Date(today);
76
+ date.setDate(date.getDate() - 1);
77
+ return date;
78
+ }
79
+ if (/前天/.test(raw)) {
80
+ const date = new Date(today);
81
+ date.setDate(date.getDate() - 2);
82
+ return date;
83
+ }
84
+
85
+ const full = raw.match(/(20\d{2})[.\-\/](\d{1,2})[.\-\/](\d{1,2})/);
86
+ if (full) {
87
+ return makeDate(
88
+ Number.parseInt(full[1], 10),
89
+ Number.parseInt(full[2], 10),
90
+ Number.parseInt(full[3], 10)
91
+ );
92
+ }
93
+
94
+ const partial = raw.match(/(?:^|\D)(\d{1,2})[.\-\/](\d{1,2})(?:\D|$)/);
95
+ if (partial) {
96
+ const reference = dateOnly(referenceDate) || new Date();
97
+ let date = makeDate(
98
+ reference.getFullYear(),
99
+ Number.parseInt(partial[1], 10),
100
+ Number.parseInt(partial[2], 10)
101
+ );
102
+ if (date && daysBetween(date, reference) > 7) {
103
+ date = makeDate(
104
+ reference.getFullYear() - 1,
105
+ Number.parseInt(partial[1], 10),
106
+ Number.parseInt(partial[2], 10)
107
+ );
108
+ }
109
+ return date;
110
+ }
111
+
112
+ return null;
113
+ }
114
+
115
+ export function isDateWithinWindow(date, {
116
+ referenceDate = new Date(),
117
+ windowDays = 14
118
+ } = {}) {
119
+ const diff = daysBetween(referenceDate, date);
120
+ return Number.isFinite(diff) && diff >= 0 && diff <= windowDays;
121
+ }
122
+
123
+ async function textForNode(client, nodeId) {
124
+ return htmlToText(await getOuterHTML(client, nodeId));
125
+ }
126
+
127
+ async function queryAcrossRoots(client, roots, selector) {
128
+ const matches = [];
129
+ for (const root of roots || []) {
130
+ if (!root?.nodeId) continue;
131
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector);
132
+ for (const nodeId of nodeIds) {
133
+ matches.push({
134
+ root: root.name,
135
+ root_node_id: root.nodeId,
136
+ selector,
137
+ node_id: nodeId
138
+ });
139
+ }
140
+ }
141
+ return matches;
142
+ }
143
+
144
+ function tabIsColleague(text) {
145
+ return normalizeText(text) === "同事沟通进度";
146
+ }
147
+
148
+ function isUsableVisibleRowBox(box) {
149
+ return Number(box?.rect?.width || 0) > 2 && Number(box?.rect?.height || 0) > 2;
150
+ }
151
+
152
+ async function readVisibleNodeEvidence(client, nodeId) {
153
+ let box = null;
154
+ try {
155
+ box = await getNodeBox(client, nodeId);
156
+ } catch {
157
+ return null;
158
+ }
159
+ if (!isUsableVisibleRowBox(box)) return null;
160
+ const node = await describeNode(client, nodeId, { depth: 0, pierce: true });
161
+ if (!Number.isInteger(node?.backendNodeId) || node.backendNodeId <= 0) return null;
162
+ return {
163
+ node_id: nodeId,
164
+ backend_node_id: node.backendNodeId,
165
+ box: {
166
+ x: box.rect.x,
167
+ y: box.rect.y,
168
+ width: box.rect.width,
169
+ height: box.rect.height
170
+ }
171
+ };
172
+ }
173
+
174
+ async function readSelectedColleagueTab(client, sectionNodeId) {
175
+ const selectedIds = await querySelectorAll(client, sectionNodeId, SECTION_SELECTED_TAB_SELECTOR);
176
+ const visibleSelected = [];
177
+ for (const nodeId of selectedIds) {
178
+ const evidence = await readVisibleNodeEvidence(client, nodeId);
179
+ if (!evidence) continue;
180
+ visibleSelected.push({
181
+ ...evidence,
182
+ text: normalizeText(await textForNode(client, nodeId))
183
+ });
184
+ }
185
+ const selected = visibleSelected.length === 1 && tabIsColleague(visibleSelected[0].text);
186
+ return {
187
+ selected,
188
+ selected_text: visibleSelected[0]?.text || "",
189
+ selected_tab_count: visibleSelected.length,
190
+ node_id: visibleSelected[0]?.node_id || null,
191
+ backend_node_id: visibleSelected[0]?.backend_node_id || null,
192
+ box: visibleSelected[0]?.box || null
193
+ };
194
+ }
195
+
196
+ async function ensureColleagueTabSelected(client, sectionNodeId) {
197
+ const before = await readSelectedColleagueTab(client, sectionNodeId);
198
+ if (before.selected) {
199
+ return {
200
+ ...before,
201
+ changed: false
202
+ };
203
+ }
204
+
205
+ const candidateIds = await querySelectorAll(client, sectionNodeId, TAB_CANDIDATE_SELECTOR);
206
+ for (const nodeId of candidateIds) {
207
+ const evidence = await readVisibleNodeEvidence(client, nodeId);
208
+ if (!evidence) continue;
209
+ const text = normalizeText(await textForNode(client, nodeId));
210
+ if (!tabIsColleague(text)) continue;
211
+ const box = await clickNodeCenter(client, nodeId, { scrollIntoView: true });
212
+ await sleep(500);
213
+ const after = await readSelectedColleagueTab(client, sectionNodeId);
214
+ return {
215
+ ...after,
216
+ changed: true,
217
+ click_box: {
218
+ rect: box.rect,
219
+ center: box.center
220
+ },
221
+ reason: after.selected ? null : "colleague_tab_selection_not_verified"
222
+ };
223
+ }
224
+
225
+ return {
226
+ ...before,
227
+ changed: false,
228
+ reason: "colleague_tab_candidate_not_visible"
229
+ };
230
+ }
231
+
232
+ async function readContactRows(client, sectionNodeId, sectionBackendNodeId, sectionRoot = "") {
233
+ const contentNodeIds = await querySelectorAll(client, sectionNodeId, SECTION_ROW_CONTENT_SELECTOR);
234
+ const fallbackNodeIds = contentNodeIds.length
235
+ ? []
236
+ : await querySelectorAll(client, sectionNodeId, SECTION_ROW_SELECTOR);
237
+ const nodeIds = contentNodeIds.length ? contentNodeIds : fallbackNodeIds;
238
+ const rows = [];
239
+ const unreadableRows = [];
240
+ const seen = new Set();
241
+ for (const nodeId of nodeIds) {
242
+ let box = null;
243
+ try {
244
+ box = await getNodeBox(client, nodeId);
245
+ } catch (error) {
246
+ unreadableRows.push({
247
+ node_id: nodeId,
248
+ stage: "box",
249
+ error: error?.message || String(error)
250
+ });
251
+ continue;
252
+ }
253
+ if (!isUsableVisibleRowBox(box)) {
254
+ unreadableRows.push({
255
+ node_id: nodeId,
256
+ stage: "visible_box"
257
+ });
258
+ continue;
259
+ }
260
+ const node = await describeNode(client, nodeId, { depth: 0, pierce: true });
261
+ if (!Number.isInteger(node?.backendNodeId) || node.backendNodeId <= 0) {
262
+ unreadableRows.push({
263
+ node_id: nodeId,
264
+ stage: "backend_identity"
265
+ });
266
+ continue;
267
+ }
268
+ const text = normalizeText(await textForNode(client, nodeId));
269
+ if (!text) {
270
+ unreadableRows.push({
271
+ node_id: nodeId,
272
+ backend_node_id: node.backendNodeId,
273
+ stage: "text"
274
+ });
275
+ continue;
276
+ }
277
+ if (seen.has(text)) continue;
278
+ seen.add(text);
279
+ rows.push({
280
+ text,
281
+ root: sectionRoot,
282
+ selector: contentNodeIds.length ? SECTION_ROW_CONTENT_SELECTOR : SECTION_ROW_SELECTOR,
283
+ node_id: nodeId,
284
+ backend_node_id: node.backendNodeId,
285
+ section_node_id: sectionNodeId,
286
+ section_backend_node_id: sectionBackendNodeId,
287
+ visible: true,
288
+ box: {
289
+ x: box.rect.x,
290
+ y: box.rect.y,
291
+ width: box.rect.width,
292
+ height: box.rect.height
293
+ }
294
+ });
295
+ }
296
+ return {
297
+ rows,
298
+ unreadable_rows: unreadableRows
299
+ };
300
+ }
301
+
302
+ function paneBindingMatches(sectionEvidence, selectedEvidence, section, tab) {
303
+ return Boolean(
304
+ sectionEvidence
305
+ && sectionEvidence.backend_node_id === section.backend_node_id
306
+ && selectedEvidence?.selected === true
307
+ && selectedEvidence.selected_tab_count === 1
308
+ && selectedEvidence.node_id === tab.node_id
309
+ && selectedEvidence.backend_node_id === tab.backend_node_id
310
+ );
311
+ }
312
+
313
+ async function readPaneBindingAtPosition(client, section, tab) {
314
+ const sectionEvidence = await readVisibleNodeEvidence(client, section.node_id);
315
+ const selectedEvidence = await readSelectedColleagueTab(client, section.node_id);
316
+ return {
317
+ verified: paneBindingMatches(sectionEvidence, selectedEvidence, section, tab),
318
+ section_visible: Boolean(sectionEvidence),
319
+ section_node_id: section.node_id,
320
+ section_backend_node_id: sectionEvidence?.backend_node_id || null,
321
+ selected_tab_node_id: selectedEvidence.node_id || null,
322
+ selected_tab_backend_node_id: selectedEvidence.backend_node_id || null,
323
+ selected_tab_text: selectedEvidence.selected_text,
324
+ selected_tab_count: selectedEvidence.selected_tab_count
325
+ };
326
+ }
327
+
328
+ async function resolveBoundScrollTarget(client, scopes, section) {
329
+ const sectionScope = (scopes || []).find((scope) => (
330
+ scope?.name === section.root
331
+ && scope?.nodeId === section.root_node_id
332
+ ));
333
+ const detailPanes = sectionScope
334
+ ? await queryAcrossRoots(client, [sectionScope], DETAIL_PANE_SELECTOR)
335
+ : [];
336
+ const visiblePanes = [];
337
+ for (const pane of detailPanes) {
338
+ const evidence = await readVisibleNodeEvidence(client, pane.node_id);
339
+ if (!evidence) continue;
340
+ visiblePanes.push({
341
+ ...pane,
342
+ ...evidence
343
+ });
344
+ }
345
+ if (visiblePanes.length > 1) {
346
+ return {
347
+ ok: false,
348
+ reason: "scroll_target_ambiguous",
349
+ visible_target_count: visiblePanes.length
350
+ };
351
+ }
352
+ if (visiblePanes.length === 1) {
353
+ return {
354
+ ok: true,
355
+ node_id: visiblePanes[0].node_id,
356
+ backend_node_id: visiblePanes[0].backend_node_id,
357
+ selector: visiblePanes[0].selector,
358
+ box: visiblePanes[0].box
359
+ };
360
+ }
361
+ const sectionEvidence = await readVisibleNodeEvidence(client, section.node_id);
362
+ if (!sectionEvidence || sectionEvidence.backend_node_id !== section.backend_node_id) {
363
+ return {
364
+ ok: false,
365
+ reason: "scroll_target_box_unavailable",
366
+ visible_target_count: 0
367
+ };
368
+ }
369
+ return {
370
+ ok: true,
371
+ node_id: section.node_id,
372
+ backend_node_id: section.backend_node_id,
373
+ selector: COLLEAGUE_SECTION_SELECTOR,
374
+ box: sectionEvidence.box
375
+ };
376
+ }
377
+
378
+ function addRowsFromPosition(byText, positionRows, positionIndex) {
379
+ for (const row of positionRows) {
380
+ const existing = byText.get(row.text);
381
+ if (existing) {
382
+ if (!existing.observed_at_positions.includes(positionIndex)) {
383
+ existing.observed_at_positions.push(positionIndex);
384
+ }
385
+ continue;
386
+ }
387
+ byText.set(row.text, {
388
+ ...row,
389
+ observed_at_positions: [positionIndex]
390
+ });
391
+ }
392
+ }
393
+
394
+ async function scanContactRowsAcrossScrollPositions(client, scopes, section, tab, {
395
+ enabled = true,
396
+ maxScrolls = 24,
397
+ settleMs = 350
398
+ } = {}) {
399
+ const stableEndSamplesRequired = 2;
400
+ const boundedMaxScrolls = enabled
401
+ ? Math.max(
402
+ stableEndSamplesRequired,
403
+ Math.min(48, Number.isFinite(Number(maxScrolls)) ? Math.floor(Number(maxScrolls)) : 24)
404
+ )
405
+ : 0;
406
+ const scrollTarget = enabled
407
+ ? await resolveBoundScrollTarget(client, scopes, section)
408
+ : null;
409
+ if (enabled && !scrollTarget?.ok) {
410
+ return {
411
+ completed: false,
412
+ coverage_verified: false,
413
+ reason: scrollTarget?.reason || "scroll_target_box_unavailable",
414
+ scrolls_requested: boundedMaxScrolls,
415
+ scrolls_completed: 0,
416
+ position_count: 0,
417
+ positions: [],
418
+ rows: []
419
+ };
420
+ }
421
+
422
+ const rowsByText = new Map();
423
+ const positions = [];
424
+ let scrollsCompleted = 0;
425
+ let previousRowSignature = null;
426
+ let previousRowLayoutSignature = null;
427
+ let stableSignatureCount = 0;
428
+ let effectiveScrollCount = 0;
429
+ let endProof = null;
430
+ const targetHeight = Number(scrollTarget?.box?.height || 0);
431
+ const stepDeltaY = enabled
432
+ ? Math.max(1, Math.min(480, Math.floor(targetHeight * 0.65)))
433
+ : 0;
434
+
435
+ for (let positionIndex = 0; positionIndex <= boundedMaxScrolls; positionIndex += 1) {
436
+ const bindingBefore = await readPaneBindingAtPosition(client, section, tab);
437
+ if (!bindingBefore.verified) {
438
+ return {
439
+ completed: false,
440
+ coverage_verified: false,
441
+ reason: "colleague_binding_lost",
442
+ scrolls_requested: boundedMaxScrolls,
443
+ scrolls_completed: scrollsCompleted,
444
+ position_count: positions.length,
445
+ positions,
446
+ rows: [],
447
+ failed_position: positionIndex,
448
+ failed_binding_phase: "before_rows",
449
+ binding: bindingBefore
450
+ };
451
+ }
452
+
453
+ const positionRead = await readContactRows(
454
+ client,
455
+ section.node_id,
456
+ section.backend_node_id,
457
+ section.root
458
+ );
459
+ const positionRows = positionRead.rows;
460
+ const bindingAfter = await readPaneBindingAtPosition(client, section, tab);
461
+ const rowIdentityKeys = positionRows
462
+ .map((row) => `${row.backend_node_id}:${row.text}`)
463
+ .sort();
464
+ const rowTexts = positionRows.map((row) => row.text).sort();
465
+ const rowSignature = JSON.stringify(rowIdentityKeys);
466
+ const orderedRows = positionRows
467
+ .map((row) => ({
468
+ backend_node_id: row.backend_node_id,
469
+ text: row.text,
470
+ x: Math.round(Number(row?.box?.x || 0)),
471
+ y: Math.round(Number(row?.box?.y || 0)),
472
+ width: Math.round(Number(row?.box?.width || 0)),
473
+ height: Math.round(Number(row?.box?.height || 0))
474
+ }))
475
+ .sort((left, right) => (
476
+ left.y - right.y
477
+ || left.x - right.x
478
+ || left.backend_node_id - right.backend_node_id
479
+ ));
480
+ const rowLayoutKeys = orderedRows.map((row) => (
481
+ `${row.text}:${row.x}:${row.y}:${row.width}:${row.height}`
482
+ ));
483
+ const rowLayoutSignature = JSON.stringify(rowLayoutKeys);
484
+ const newRowTexts = rowTexts.filter((text) => !rowsByText.has(text));
485
+ const scrollEffectObserved = Boolean(
486
+ scrollsCompleted > 0
487
+ && rowLayoutSignature !== previousRowLayoutSignature
488
+ );
489
+ if (scrollEffectObserved) effectiveScrollCount += 1;
490
+ if (
491
+ scrollsCompleted > 0
492
+ && rowSignature === previousRowSignature
493
+ && newRowTexts.length === 0
494
+ ) {
495
+ stableSignatureCount += 1;
496
+ } else {
497
+ stableSignatureCount = 0;
498
+ }
499
+ const positionEvidence = {
500
+ position_index: positionIndex,
501
+ sampled_after_scroll_count: scrollsCompleted,
502
+ row_count: positionRows.length,
503
+ unreadable_row_count: positionRead.unreadable_rows.length,
504
+ row_backend_node_ids: positionRows.map((row) => row.backend_node_id),
505
+ row_texts: rowTexts,
506
+ row_identity_keys: rowIdentityKeys,
507
+ row_signature: rowSignature,
508
+ ordered_row_layout: orderedRows,
509
+ ordered_row_layout_keys: rowLayoutKeys,
510
+ row_layout_signature: rowLayoutSignature,
511
+ scroll_effect_observed: scrollEffectObserved,
512
+ cumulative_effective_scroll_count: effectiveScrollCount,
513
+ new_row_count: newRowTexts.length,
514
+ new_row_texts: newRowTexts,
515
+ stable_signature_count: stableSignatureCount,
516
+ binding_before_verified: bindingBefore.verified,
517
+ binding_after_verified: bindingAfter.verified
518
+ };
519
+ positions.push(positionEvidence);
520
+ if (!bindingAfter.verified) {
521
+ return {
522
+ completed: false,
523
+ coverage_verified: false,
524
+ reason: "colleague_binding_lost",
525
+ scrolls_requested: boundedMaxScrolls,
526
+ scrolls_completed: scrollsCompleted,
527
+ position_count: positions.length,
528
+ positions,
529
+ rows: [],
530
+ failed_position: positionIndex,
531
+ failed_binding_phase: "after_rows",
532
+ binding: bindingAfter
533
+ };
534
+ }
535
+ if (positionRead.unreadable_rows.length) {
536
+ return {
537
+ completed: false,
538
+ coverage_verified: false,
539
+ reason: "colleague_row_evidence_unavailable",
540
+ scrolls_requested: boundedMaxScrolls,
541
+ scrolls_completed: scrollsCompleted,
542
+ position_count: positions.length,
543
+ positions,
544
+ rows: [],
545
+ failed_position: positionIndex,
546
+ unreadable_rows: positionRead.unreadable_rows
547
+ };
548
+ }
549
+ addRowsFromPosition(rowsByText, positionRows, positionIndex);
550
+ previousRowSignature = rowSignature;
551
+ previousRowLayoutSignature = rowLayoutSignature;
552
+
553
+ if (
554
+ enabled
555
+ && stableSignatureCount >= stableEndSamplesRequired
556
+ && effectiveScrollCount > 0
557
+ ) {
558
+ endProof = {
559
+ verified: true,
560
+ method: "effective_scroll_then_repeated_identical_rows",
561
+ stable_samples_required: stableEndSamplesRequired,
562
+ stable_samples_observed: stableSignatureCount,
563
+ effective_scroll_observed: true,
564
+ effective_scroll_count: effectiveScrollCount,
565
+ end_position_index: positionIndex,
566
+ end_scroll_count: scrollsCompleted,
567
+ row_signature: rowSignature,
568
+ additional_wheel_attempts_without_change: stableSignatureCount
569
+ };
570
+ break;
571
+ }
572
+
573
+ if (positionIndex === boundedMaxScrolls) break;
574
+
575
+ const freshTarget = await readVisibleNodeEvidence(client, scrollTarget.node_id);
576
+ if (!freshTarget || freshTarget.backend_node_id !== scrollTarget.backend_node_id) {
577
+ return {
578
+ completed: false,
579
+ coverage_verified: false,
580
+ reason: "scroll_target_binding_lost",
581
+ scrolls_requested: boundedMaxScrolls,
582
+ scrolls_completed: scrollsCompleted,
583
+ position_count: positions.length,
584
+ positions,
585
+ rows: [],
586
+ failed_position: positionIndex,
587
+ target_selector: scrollTarget.selector
588
+ };
589
+ }
590
+ try {
591
+ await client.Input.dispatchMouseEvent({
592
+ type: "mouseWheel",
593
+ x: freshTarget.box.x + (freshTarget.box.width / 2),
594
+ y: freshTarget.box.y + (freshTarget.box.height / 2),
595
+ deltaY: stepDeltaY,
596
+ deltaX: 0
597
+ });
598
+ } catch (error) {
599
+ return {
600
+ completed: false,
601
+ coverage_verified: false,
602
+ reason: "scroll_dispatch_failed",
603
+ error: error?.message || String(error),
604
+ scrolls_requested: boundedMaxScrolls,
605
+ scrolls_completed: scrollsCompleted,
606
+ position_count: positions.length,
607
+ positions,
608
+ rows: [],
609
+ failed_position: positionIndex,
610
+ target_selector: scrollTarget.selector
611
+ };
612
+ }
613
+ scrollsCompleted += 1;
614
+ await sleep(Math.max(0, Number(settleMs) || 0));
615
+ }
616
+
617
+ const allPositionEvidenceVerified = positions.every((position) => (
618
+ position.binding_before_verified === true
619
+ && position.binding_after_verified === true
620
+ ));
621
+ const completed = enabled
622
+ ? Boolean(endProof?.verified && allPositionEvidenceVerified)
623
+ : Boolean(positions.length === 1 && scrollsCompleted === 0 && allPositionEvidenceVerified);
624
+ const coverageVerified = Boolean(
625
+ enabled
626
+ && completed
627
+ && endProof?.verified === true
628
+ && positions.every((position) => position.row_count > 0)
629
+ );
630
+ const capReachedWithoutEnd = Boolean(
631
+ enabled
632
+ && scrollsCompleted >= boundedMaxScrolls
633
+ && endProof?.verified !== true
634
+ );
635
+ return {
636
+ completed,
637
+ coverage_verified: coverageVerified,
638
+ reason: completed
639
+ ? !enabled
640
+ ? "scroll_scan_disabled"
641
+ : coverageVerified
642
+ ? null
643
+ : "scroll_position_rows_missing"
644
+ : capReachedWithoutEnd
645
+ ? "scroll_end_not_verified_before_cap"
646
+ : "scroll_scan_incomplete",
647
+ scrolls_requested: boundedMaxScrolls,
648
+ scrolls_completed: scrollsCompleted,
649
+ position_count: positions.length,
650
+ positions,
651
+ target_selector: scrollTarget?.selector || null,
652
+ target_node_id: scrollTarget?.node_id || null,
653
+ target_backend_node_id: scrollTarget?.backend_node_id || null,
654
+ step_delta_y: stepDeltaY,
655
+ overlap_ratio: 0.35,
656
+ effective_scroll_count: effectiveScrollCount,
657
+ end_proof: endProof || {
658
+ verified: false,
659
+ method: "effective_scroll_then_repeated_identical_rows",
660
+ stable_samples_required: stableEndSamplesRequired,
661
+ stable_samples_observed: stableSignatureCount,
662
+ effective_scroll_observed: effectiveScrollCount > 0,
663
+ effective_scroll_count: effectiveScrollCount,
664
+ end_position_index: null,
665
+ end_scroll_count: null,
666
+ row_signature: null,
667
+ additional_wheel_attempts_without_change: stableSignatureCount
668
+ },
669
+ cap_reached_without_end: capReachedWithoutEnd,
670
+ coverage_gap_positions: positions
671
+ .filter((position) => position.row_count <= 0)
672
+ .map((position) => position.position_index),
673
+ rows: Array.from(rowsByText.values())
674
+ };
675
+ }
676
+
677
+ async function resolveColleagueDetailScopes(client, detailState) {
678
+ const scopes = [];
679
+ if (Number.isInteger(detailState?.popup?.node_id) && detailState.popup.node_id > 0) {
680
+ const popupEvidence = await readVisibleNodeEvidence(client, detailState.popup.node_id);
681
+ if (popupEvidence) {
682
+ scopes.push({
683
+ name: "popup",
684
+ nodeId: detailState.popup.node_id,
685
+ backendNodeId: popupEvidence.backend_node_id
686
+ });
687
+ }
688
+ }
689
+ if (Number.isInteger(detailState?.resumeIframe?.node_id) && detailState.resumeIframe.node_id > 0) {
690
+ try {
691
+ const documentNodeId = await getFrameDocumentNodeId(client, detailState.resumeIframe.node_id);
692
+ scopes.push({
693
+ name: "resume_iframe",
694
+ nodeId: documentNodeId,
695
+ backendNodeId: null
696
+ });
697
+ } catch {
698
+ // A stale or inaccessible iframe cannot be used as colleague-contact evidence.
699
+ }
700
+ }
701
+ return scopes;
702
+ }
703
+
704
+ async function waitForColleagueSections(client, scopes, {
705
+ timeoutMs = 1000,
706
+ intervalMs = 150
707
+ } = {}) {
708
+ const started = Date.now();
709
+ let sections = [];
710
+ let pollCount = 0;
711
+ let stableScopeCount = 0;
712
+ let scopeBindingLost = false;
713
+ do {
714
+ pollCount += 1;
715
+ stableScopeCount = 0;
716
+ for (const scope of scopes) {
717
+ if (!Number.isInteger(scope?.backendNodeId) || scope.backendNodeId <= 0) continue;
718
+ const evidence = await readVisibleNodeEvidence(client, scope.nodeId);
719
+ if (!evidence || evidence.backend_node_id !== scope.backendNodeId) {
720
+ scopeBindingLost = true;
721
+ break;
722
+ }
723
+ stableScopeCount += 1;
724
+ }
725
+ if (scopeBindingLost) break;
726
+ const matches = await queryAcrossRoots(client, scopes, COLLEAGUE_SECTION_SELECTOR);
727
+ sections = [];
728
+ for (const match of matches) {
729
+ const evidence = await readVisibleNodeEvidence(client, match.node_id);
730
+ if (!evidence) continue;
731
+ sections.push({
732
+ ...match,
733
+ backend_node_id: evidence.backend_node_id,
734
+ visible: true,
735
+ box: evidence.box
736
+ });
737
+ }
738
+ if (sections.length) {
739
+ return {
740
+ sections,
741
+ absence_probe: {
742
+ verified: false,
743
+ selector: COLLEAGUE_SECTION_SELECTOR,
744
+ scope_count: scopes.length,
745
+ stable_scope_count: stableScopeCount,
746
+ poll_count: pollCount,
747
+ elapsed_ms: Date.now() - started,
748
+ timeout_ms: Math.max(0, Number(timeoutMs) || 0),
749
+ full_window_elapsed: false,
750
+ query_error_count: 0,
751
+ scope_binding_lost: false,
752
+ scope_backend_node_ids: scopes
753
+ .map((scope) => Number(scope?.backendNodeId))
754
+ .filter((backendNodeId) => Number.isInteger(backendNodeId) && backendNodeId > 0)
755
+ }
756
+ };
757
+ }
758
+ const elapsedMs = Date.now() - started;
759
+ const normalizedTimeoutMs = Math.max(0, Number(timeoutMs) || 0);
760
+ if (
761
+ elapsedMs >= normalizedTimeoutMs
762
+ && (normalizedTimeoutMs === 0 || pollCount >= 2)
763
+ ) break;
764
+ await sleep(Math.max(1, Number(intervalMs) || 0));
765
+ } while (true);
766
+ const elapsedMs = Date.now() - started;
767
+ const normalizedTimeoutMs = Math.max(0, Number(timeoutMs) || 0);
768
+ const stableBackendNodeIds = scopes
769
+ .map((scope) => Number(scope?.backendNodeId))
770
+ .filter((backendNodeId) => Number.isInteger(backendNodeId) && backendNodeId > 0);
771
+ return {
772
+ sections,
773
+ absence_probe: {
774
+ verified: Boolean(
775
+ !scopeBindingLost
776
+ && scopes.length > 0
777
+ && stableScopeCount > 0
778
+ && stableBackendNodeIds.length === stableScopeCount
779
+ && pollCount >= 2
780
+ && elapsedMs >= normalizedTimeoutMs
781
+ ),
782
+ selector: COLLEAGUE_SECTION_SELECTOR,
783
+ scope_count: scopes.length,
784
+ stable_scope_count: stableScopeCount,
785
+ poll_count: pollCount,
786
+ elapsed_ms: elapsedMs,
787
+ timeout_ms: normalizedTimeoutMs,
788
+ full_window_elapsed: elapsedMs >= normalizedTimeoutMs,
789
+ query_error_count: 0,
790
+ scope_binding_lost: scopeBindingLost,
791
+ scope_backend_node_ids: stableBackendNodeIds
792
+ }
793
+ };
794
+ }
795
+
796
+ export async function inspectRecentColleagueContact(client, detailState, {
797
+ referenceDate = new Date(),
798
+ windowDays = 14,
799
+ scroll = true,
800
+ scrollMaxSteps = 24,
801
+ scrollSettleMs = 350,
802
+ sectionWaitMs = 1000,
803
+ sectionPollMs = 150
804
+ } = {}) {
805
+ const scopes = await resolveColleagueDetailScopes(client, detailState);
806
+ if (!scopes.length) {
807
+ return {
808
+ checked: false,
809
+ panel_found: false,
810
+ recent: null,
811
+ indeterminate: true,
812
+ reason: "detail_scope_unavailable",
813
+ window_days: windowDays,
814
+ rows: []
815
+ };
816
+ }
817
+ const sectionWait = await waitForColleagueSections(client, scopes, {
818
+ timeoutMs: sectionWaitMs,
819
+ intervalMs: sectionPollMs
820
+ });
821
+ const sections = sectionWait.sections;
822
+ if (!sections.length) {
823
+ const absenceVerified = sectionWait.absence_probe?.verified === true;
824
+ return {
825
+ checked: absenceVerified,
826
+ panel_found: false,
827
+ recent: absenceVerified ? false : null,
828
+ indeterminate: !absenceVerified,
829
+ reason: "panel_missing",
830
+ window_days: windowDays,
831
+ absence_probe: sectionWait.absence_probe,
832
+ rows: []
833
+ };
834
+ }
835
+ if (sections.length !== 1) {
836
+ return {
837
+ checked: false,
838
+ panel_found: true,
839
+ recent: null,
840
+ indeterminate: true,
841
+ reason: "panel_ambiguous",
842
+ window_days: windowDays,
843
+ visible_section_count: sections.length,
844
+ rows: []
845
+ };
846
+ }
847
+
848
+ const section = sections[0];
849
+ const tabHeaderIds = await querySelectorAll(client, section.node_id, ".tab-hd");
850
+ let visibleTabHeaderCount = 0;
851
+ for (const nodeId of tabHeaderIds) {
852
+ if (await readVisibleNodeEvidence(client, nodeId)) visibleTabHeaderCount += 1;
853
+ }
854
+ const tab = await ensureColleagueTabSelected(client, section.node_id);
855
+ if (!tab.selected) {
856
+ return {
857
+ checked: false,
858
+ panel_found: true,
859
+ recent: null,
860
+ indeterminate: true,
861
+ reason: "colleague_tab_unavailable",
862
+ window_days: windowDays,
863
+ section_root: section.root,
864
+ section_node_id: section.node_id,
865
+ section_backend_node_id: section.backend_node_id,
866
+ tab_header_found: visibleTabHeaderCount === 1,
867
+ selected_tab_text: tab.selected_text,
868
+ selected_tab_count: tab.selected_tab_count,
869
+ pane_binding_verified: false,
870
+ rows: []
871
+ };
872
+ }
873
+
874
+ const scrollScan = await scanContactRowsAcrossScrollPositions(
875
+ client,
876
+ scopes,
877
+ section,
878
+ tab,
879
+ {
880
+ enabled: scroll,
881
+ maxScrolls: scrollMaxSteps,
882
+ settleMs: scrollSettleMs
883
+ }
884
+ );
885
+ const {
886
+ rows = [],
887
+ ...scroll_probe
888
+ } = scrollScan;
889
+
890
+ const sectionAfter = await readVisibleNodeEvidence(client, section.node_id);
891
+ const selectedAfter = await readSelectedColleagueTab(client, section.node_id);
892
+ const binding = {
893
+ verified: Boolean(
894
+ sectionAfter
895
+ && sectionAfter.backend_node_id === section.backend_node_id
896
+ && tab.selected === true
897
+ && selectedAfter.selected === true
898
+ && selectedAfter.backend_node_id === tab.backend_node_id
899
+ && selectedAfter.selected_tab_count === 1
900
+ ),
901
+ detail_root_node_id: scopes[0]?.nodeId || null,
902
+ section_node_id: section.node_id,
903
+ section_backend_node_id: section.backend_node_id,
904
+ section_visible: Boolean(sectionAfter),
905
+ selected_tab_node_id: tab.node_id || null,
906
+ selected_tab_backend_node_id: tab.backend_node_id || null,
907
+ selected_tab_text: tab.selected_text,
908
+ selected_tab_visible: tab.selected === true,
909
+ selected_tab_count: tab.selected_tab_count,
910
+ selection_reverified_after_rows: selectedAfter.selected === true,
911
+ selected_tab_backend_node_id_after_rows: selectedAfter.backend_node_id || null,
912
+ row_scope: "selected_section_descendants"
913
+ };
914
+ if (!binding.verified) {
915
+ return {
916
+ checked: false,
917
+ panel_found: true,
918
+ recent: null,
919
+ indeterminate: true,
920
+ reason: "colleague_binding_lost",
921
+ window_days: windowDays,
922
+ section_root: section.root,
923
+ section_node_id: section.node_id,
924
+ section_backend_node_id: section.backend_node_id,
925
+ tab_header_found: visibleTabHeaderCount === 1,
926
+ selected_tab_text: selectedAfter.selected_text || tab.selected_text,
927
+ selected_tab_count: selectedAfter.selected_tab_count,
928
+ pane_binding_verified: false,
929
+ binding,
930
+ rows: [],
931
+ scroll_probe
932
+ };
933
+ }
934
+
935
+ const parsedRows = rows.map((row) => {
936
+ const parsedDate = parseColleagueContactDate(row.text, { referenceDate });
937
+ return {
938
+ ...row,
939
+ parsed_date: parsedDate ? formatLocalDate(parsedDate) : null,
940
+ within_window: parsedDate
941
+ ? isDateWithinWindow(parsedDate, { referenceDate, windowDays })
942
+ : false
943
+ };
944
+ });
945
+ const matched = parsedRows.find((row) => row.within_window) || null;
946
+ if (matched) {
947
+ return {
948
+ checked: true,
949
+ panel_found: true,
950
+ recent: true,
951
+ indeterminate: false,
952
+ reason: "recent_colleague_contact_found",
953
+ window_days: windowDays,
954
+ section_root: section.root,
955
+ section_node_id: section.node_id,
956
+ section_backend_node_id: section.backend_node_id,
957
+ tab_header_found: visibleTabHeaderCount === 1,
958
+ selected_tab_text: tab.selected_text,
959
+ selected_tab_count: tab.selected_tab_count,
960
+ pane_binding_verified: true,
961
+ binding,
962
+ tab_changed: tab.changed,
963
+ matched_row: matched,
964
+ row_count: parsedRows.length,
965
+ rows: parsedRows,
966
+ scroll_probe
967
+ };
968
+ }
969
+ if (!scroll_probe.completed) {
970
+ return {
971
+ checked: false,
972
+ panel_found: true,
973
+ recent: null,
974
+ indeterminate: true,
975
+ reason: scroll_probe.reason || "colleague_scroll_scan_incomplete",
976
+ window_days: windowDays,
977
+ section_root: section.root,
978
+ section_node_id: section.node_id,
979
+ section_backend_node_id: section.backend_node_id,
980
+ tab_header_found: visibleTabHeaderCount === 1,
981
+ selected_tab_text: tab.selected_text,
982
+ selected_tab_count: tab.selected_tab_count,
983
+ pane_binding_verified: true,
984
+ binding,
985
+ rows: parsedRows,
986
+ scroll_probe
987
+ };
988
+ }
989
+ if (!parsedRows.length) {
990
+ return {
991
+ checked: false,
992
+ panel_found: true,
993
+ recent: null,
994
+ indeterminate: true,
995
+ reason: "contact_rows_missing",
996
+ window_days: windowDays,
997
+ section_root: section.root,
998
+ section_node_id: section.node_id,
999
+ section_backend_node_id: section.backend_node_id,
1000
+ tab_header_found: visibleTabHeaderCount === 1,
1001
+ selected_tab_text: tab.selected_text,
1002
+ selected_tab_count: tab.selected_tab_count,
1003
+ pane_binding_verified: true,
1004
+ binding,
1005
+ tab_changed: tab.changed,
1006
+ row_count: 0,
1007
+ rows: [],
1008
+ scroll_probe
1009
+ };
1010
+ }
1011
+ const unparsedRows = parsedRows.filter((row) => !row.parsed_date);
1012
+ if (unparsedRows.length) {
1013
+ return {
1014
+ checked: false,
1015
+ panel_found: true,
1016
+ recent: null,
1017
+ indeterminate: true,
1018
+ reason: "contact_date_unparseable",
1019
+ window_days: windowDays,
1020
+ section_root: section.root,
1021
+ section_node_id: section.node_id,
1022
+ section_backend_node_id: section.backend_node_id,
1023
+ tab_header_found: visibleTabHeaderCount === 1,
1024
+ selected_tab_text: tab.selected_text,
1025
+ selected_tab_count: tab.selected_tab_count,
1026
+ pane_binding_verified: true,
1027
+ binding,
1028
+ tab_changed: tab.changed,
1029
+ row_count: parsedRows.length,
1030
+ unparsed_row_count: unparsedRows.length,
1031
+ unparsed_rows: unparsedRows,
1032
+ rows: parsedRows,
1033
+ scroll_probe
1034
+ };
1035
+ }
1036
+ if (!scroll_probe.coverage_verified) {
1037
+ return {
1038
+ checked: false,
1039
+ panel_found: true,
1040
+ recent: null,
1041
+ indeterminate: true,
1042
+ reason: scroll_probe.reason || "colleague_scroll_scan_incomplete",
1043
+ window_days: windowDays,
1044
+ section_root: section.root,
1045
+ section_node_id: section.node_id,
1046
+ section_backend_node_id: section.backend_node_id,
1047
+ tab_header_found: visibleTabHeaderCount === 1,
1048
+ selected_tab_text: tab.selected_text,
1049
+ selected_tab_count: tab.selected_tab_count,
1050
+ pane_binding_verified: true,
1051
+ binding,
1052
+ tab_changed: tab.changed,
1053
+ row_count: parsedRows.length,
1054
+ rows: parsedRows,
1055
+ scroll_probe
1056
+ };
1057
+ }
1058
+ return {
1059
+ checked: true,
1060
+ panel_found: true,
1061
+ recent: false,
1062
+ indeterminate: false,
1063
+ reason: "no_recent_colleague_contact",
1064
+ window_days: windowDays,
1065
+ section_root: section.root,
1066
+ section_node_id: section.node_id,
1067
+ section_backend_node_id: section.backend_node_id,
1068
+ tab_header_found: visibleTabHeaderCount === 1,
1069
+ selected_tab_text: tab.selected_text,
1070
+ selected_tab_count: tab.selected_tab_count,
1071
+ pane_binding_verified: true,
1072
+ binding,
1073
+ tab_changed: tab.changed,
1074
+ matched_row: null,
1075
+ row_count: parsedRows.length,
1076
+ rows: parsedRows,
1077
+ scroll_probe
1078
+ };
1079
+ }