halo-agent 2.2.6 → 2.4.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.
package/orchestrator.js CHANGED
@@ -53,9 +53,18 @@ async function fillFields(page, aep, opts) {
53
53
  }
54
54
  const { detectCaptcha, solveCaptcha, injectCaptchaToken } = require('./captcha');
55
55
  const { visionFill, visionNavigateAndSubmit, visionFillSkipped } = require('./vision');
56
-
57
- // ATS types that need vision fallback due to shadow DOM / canvas
58
- const VISION_ATS = new Set(['workday', 'icims', 'taleo', 'sap', 'successfactors']);
56
+ const { scanWorkday } = require('./scanWorkday');
57
+ const { scanAccessibility } = require('./scanAccessibility');
58
+ const { findApplyPath } = require('./findApplyPath');
59
+
60
+ // ATS types that need vision fallback due to shadow DOM / canvas.
61
+ // 'workday' was here historically because the AX-tree scan returned no usable
62
+ // fields on Workday's shadow-DOM forms. Phase 3 of the Workday end-to-end fix
63
+ // added scanWorkday() (DOM walk via [data-automation-id^="formField-"]) +
64
+ // pickWorkdayOption (scroll-and-scrape for long dropdowns), so Workday is now
65
+ // DOM-first like Greenhouse/Lever, with vision reserved as fallback only.
66
+ const VISION_ATS = new Set(['icims', 'taleo', 'sap', 'successfactors']);
67
+ const WORKDAY_ATS = 'workday';
59
68
 
60
69
  /**
61
70
  * Create a fresh FormContext for tracking state across a multi-page application.
@@ -157,6 +166,36 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
157
166
  // May return a new page object if Apply opened a new tab.
158
167
  page = await clickApplyIfNeeded(page);
159
168
 
169
+ // STEP 1.5: Find the apply path if the page still has no fields.
170
+ //
171
+ // clickApplyIfNeeded only handles obvious cases (a literal "Apply" button
172
+ // outside the form). It misses:
173
+ // - tabs (Overview / Application on Mercor's Ashby-wrapped careers page)
174
+ // - "Continue to application" CTAs below the fold
175
+ // - non-semantic styled divs that act as the entry point
176
+ //
177
+ // findApplyPath checks the cache, then probes the AX tree for apply-keyword
178
+ // clickables, then falls back to vision. Bounded to 2 click hops. If it
179
+ // still can't find fields, we surface NEEDS_ATTENTION below instead of the
180
+ // blind resume-uploader hack.
181
+ try {
182
+ const earlyScan = await scanAccessibility(page);
183
+ if (!earlyScan || earlyScan.length === 0) {
184
+ console.log('[orchestrator] No fields after clickApplyIfNeeded invoking findApplyPath');
185
+ const result = await findApplyPath(page, { config, anthropicKey });
186
+ if (result.found) {
187
+ console.log(`[orchestrator] findApplyPath: ${result.method} succeeded (${result.fields_after} fields)`);
188
+ } else {
189
+ console.warn('[orchestrator] findApplyPath: no apply path found form may need manual navigation');
190
+ // We continue anyway handlePageGate / smartFill may still find
191
+ // something. If everything fails downstream, the existing NEEDS_ATTENTION
192
+ // surface fires from the orchestrator's terminal error handler.
193
+ }
194
+ }
195
+ } catch (e) {
196
+ console.warn(`[orchestrator] findApplyPath threw: ${e.message}`);
197
+ }
198
+
160
199
  // STEP 2: Vision gate detection — handles modals, resume prompts, login walls, preamble screens.
161
200
  // Runs before captcha check so the form is actually visible before we try to fill it.
162
201
  const gateResult = await handlePageGate(page, aep, tempResumeFile, config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.2.6",
3
+ "version": "2.4.0",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "filler.js",
24
24
  "scanPage.js",
25
25
  "scanAccessibility.js",
26
+ "scanWorkday.js",
26
27
  "smartFill.js",
27
28
  "detectFormErrors.js",
28
29
  "captcha.js",
@@ -609,4 +609,4 @@ async function scanAccessibility(page) {
609
609
  return out;
610
610
  }
611
611
 
612
- module.exports = { scanAccessibility };
612
+ module.exports = { scanAccessibility, injectMmid };
package/scanWorkday.js ADDED
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Workday DOM scanner (Phase 3 of the Workday end-to-end fix).
5
+ *
6
+ * Workday forms live in a React shadow-DOM and the canonical anchor for every
7
+ * fillable widget is `[data-automation-id^="formField-"]`. The public repo
8
+ * shanjilcoding/resume-tailor-public's workday.js proved this approach is
9
+ * reliable enough for the common case — we borrow the SELECTORS and
10
+ * HEURISTICS (not the bookmarklet runtime) into Playwright.
11
+ *
12
+ * Why it exists: previously the agent treated Workday as VISION-ONLY in
13
+ * orchestrator.js (VISION_ATS set), which meant every Workday job cost a
14
+ * Claude Opus call per field (~40 iterations / $0.06 per job, slow). This
15
+ * scanner returns fields in the same shape scanAccessibility produces, so the
16
+ * existing smartFillPage / planner pipeline can drive Workday DOM-first.
17
+ * Vision stays as a fallback for fields this DOM pass misses (custom widgets,
18
+ * vendor-extended Workday tenants).
19
+ *
20
+ * Output shape (matches scanAccessibility for executor compatibility):
21
+ * {
22
+ * mmid, role, label, description, required, options, selectorHint,
23
+ * inputType, value, isTypeahead, groupLabel, filledAlready,
24
+ * }
25
+ */
26
+
27
+ const { injectMmid } = require('./scanAccessibility');
28
+
29
+ async function scanWorkday(page) {
30
+ await injectMmid(page).catch(() => 0);
31
+
32
+ return await page.evaluate(() => {
33
+ function clean(s) { return String(s || '').replace(/\s+/g, ' ').trim(); }
34
+
35
+ // Workday inputs live INSIDE the formField wrapper; pick the most
36
+ // representative one (the visible interactive element).
37
+ function pickInner(root) {
38
+ return root.querySelector(
39
+ 'input:not([type=hidden]):not([type=submit]),' +
40
+ 'textarea, select,' +
41
+ '[role="combobox"], button[aria-haspopup="listbox"], button[aria-haspopup="dialog"],' +
42
+ '[role="checkbox"], [role="radio"]'
43
+ );
44
+ }
45
+
46
+ // Map DOM tag/role → the same role string scanAccessibility uses, so the
47
+ // planner doesn't need a special case for Workday.
48
+ function inferRole(root, inner) {
49
+ if (!inner) return 'textbox';
50
+ const tag = (inner.tagName || '').toLowerCase();
51
+ const role = inner.getAttribute('role');
52
+ const type = (inner.getAttribute('type') || '').toLowerCase();
53
+ if (tag === 'textarea') return 'textarea';
54
+ if (tag === 'select') return 'listbox';
55
+ if (role === 'combobox' || (tag === 'button' && /listbox|dialog/.test(inner.getAttribute('aria-haspopup') || ''))) return 'combobox';
56
+ if (role === 'checkbox' || type === 'checkbox') return 'checkbox';
57
+ if (role === 'radio' || type === 'radio') return 'radio';
58
+ if (type === 'file' || root.querySelector('input[type=file]')) return 'file_upload';
59
+ return 'textbox';
60
+ }
61
+
62
+ function nearestLabel(root, inner) {
63
+ // 1. label[for=id]
64
+ if (inner && inner.id) {
65
+ const lbl = document.querySelector(`label[for="${inner.id}"]`);
66
+ if (lbl) return clean(lbl.innerText || lbl.textContent || '').replace(/\*\s*$/, '').trim();
67
+ }
68
+ // 2. aria-labelledby
69
+ const ariaLbl = (inner && inner.getAttribute('aria-labelledby')) || root.getAttribute('aria-labelledby');
70
+ if (ariaLbl) {
71
+ const t = ariaLbl.split(/\s+/).map((id) => document.getElementById(id)).filter(Boolean)
72
+ .map((n) => clean(n.innerText || '')).join(' ').trim();
73
+ if (t) return t.replace(/\*\s*$/, '').trim();
74
+ }
75
+ // 3. A label/legend inside the formField wrapper (Workday's typical layout)
76
+ const lbl = root.querySelector('label, legend, [class*="label"]');
77
+ if (lbl) return clean(lbl.innerText || '').replace(/\*\s*$/, '').trim();
78
+ // 4. data-automation-id minus prefix as a last resort
79
+ const daid = root.getAttribute('data-automation-id') || '';
80
+ return daid.replace(/^formField-/, '').replace(/[-_]/g, ' ').trim() || '(unknown)';
81
+ }
82
+
83
+ function isRequired(root, inner) {
84
+ if (inner) {
85
+ if (inner.required) return true;
86
+ if (inner.getAttribute('aria-required') === 'true') return true;
87
+ }
88
+ // Workday marks required labels with a trailing * inside the wrapper.
89
+ const lbl = root.querySelector('label, legend');
90
+ const raw = lbl ? (lbl.innerText || lbl.textContent || '') : '';
91
+ return /\*\s*$/.test(raw.trim()) || /\brequired\b/i.test(raw);
92
+ }
93
+
94
+ // Get options if they're eagerly rendered. Workday's dropdowns mount their
95
+ // listbox on click, so this often returns null — smartFill's openAndPickOption
96
+ // already handles the click-then-look-then-scrape pattern.
97
+ function eagerOptions(root) {
98
+ const opts = [];
99
+ root.querySelectorAll('option, [role="option"]').forEach((o) => {
100
+ const t = clean(o.innerText || o.textContent || '');
101
+ if (t && opts.indexOf(t) === -1) opts.push(t);
102
+ });
103
+ return opts.length > 0 ? opts.slice(0, 200) : null;
104
+ }
105
+
106
+ function isCurrentlyFilled(inner, root) {
107
+ if (!inner) return false;
108
+ const tag = (inner.tagName || '').toLowerCase();
109
+ const type = (inner.getAttribute('type') || '').toLowerCase();
110
+ if (type === 'checkbox' || type === 'radio') return !!inner.checked;
111
+ if (type === 'file') return !!(inner.files && inner.files.length > 0);
112
+ if (tag === 'select') return !!inner.value && !!inner.value.trim();
113
+ // Combobox / button trigger: look for the value display sibling.
114
+ if (inner.getAttribute('role') === 'combobox' || tag === 'button') {
115
+ // Workday renders the selected value in a span with the displayed text.
116
+ const valEl = root.querySelector('[data-automation-id*="value"], [class*="selectedValue"]');
117
+ if (valEl) return !!clean(valEl.innerText || valEl.textContent || '');
118
+ }
119
+ return !!clean(inner.value || '');
120
+ }
121
+
122
+ const results = [];
123
+ const seen = new Set();
124
+ document.querySelectorAll('[data-automation-id^="formField-"]').forEach((node) => {
125
+ const root = node;
126
+ const daid = root.getAttribute('data-automation-id') || '';
127
+ // De-dup: some Workday templates nest formField wrappers; keep the outermost.
128
+ if (seen.has(daid)) return;
129
+ seen.add(daid);
130
+ const inner = pickInner(root);
131
+ const mmid = (inner && inner.getAttribute('mmid')) || root.getAttribute('mmid') || null;
132
+ if (!mmid) return; // injectMmid skipped it — likely not interactive
133
+ const label = nearestLabel(root, inner);
134
+ const role = inferRole(root, inner);
135
+ results.push({
136
+ mmid: String(mmid),
137
+ role,
138
+ label,
139
+ description: (inner && (inner.getAttribute('aria-description') || inner.getAttribute('placeholder'))) || null,
140
+ required: isRequired(root, inner),
141
+ options: eagerOptions(root),
142
+ selectorHint: `[data-automation-id="${daid}"]`,
143
+ inputType: (inner && inner.getAttribute('type')) || null,
144
+ value: (inner && clean(inner.value || '')) || '',
145
+ isTypeahead: role === 'combobox',
146
+ groupLabel: null,
147
+ filledAlready: isCurrentlyFilled(inner, root),
148
+ });
149
+ });
150
+ return results;
151
+ }).catch(() => []);
152
+ }
153
+
154
+ module.exports = { scanWorkday };
package/smartFill.js CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  const { scanAccessibility } = require('./scanAccessibility');
20
+ const { scanWorkday } = require('./scanWorkday');
20
21
  const { fillFields: legacyFillFields } = require('./filler');
21
22
 
22
23
  // ── Plan executor ───────────────────────────────────────────────────────────
@@ -503,6 +504,133 @@ async function tryReactSelect(root, triggerLocator, value) {
503
504
  }
504
505
  }
505
506
 
507
+ /**
508
+ * Workday combobox / multi-select option picker.
509
+ *
510
+ * Workday's dropdowns mount a popover with [data-automation-id="activeListContainer"]
511
+ * holding [data-automation-id="promptOption"] entries. Long lists (country,
512
+ * university, state) are VIRTUALIZED — only ~10 entries render at once; you
513
+ * have to scroll the container to materialize the rest. The legacy
514
+ * openAndPickOption path would click + look once + give up on a 5-of-200 hit
515
+ * rate, which is why VISION_ATS = ['workday'] existed in the first place.
516
+ *
517
+ * Strategy (borrowed from shanjilcoding/resume-tailor-public/src/lib/workday.js
518
+ * lines ~600-750):
519
+ * 1. Click trigger, wait for activeListContainer.
520
+ * 2. Type the value to filter (Workday narrows the list as you type — saves
521
+ * most of the scrolling).
522
+ * 3. Snapshot visible promptOptions, scroll the container, snapshot again.
523
+ * Repeat until the option set is stable for 2 consecutive rounds OR we
524
+ * exceed a scroll budget.
525
+ * 4. Fuzzy-match the typed value against the union of all option texts.
526
+ * 5. Click the matching promptOption; verify the trigger now shows a value.
527
+ */
528
+ async function pickWorkdayOption(root, triggerLocator, value) {
529
+ const page = (root && typeof root.page === 'function') ? root.page() : root;
530
+ try {
531
+ // Open the dropdown by clicking the trigger. Workday's trigger is often
532
+ // a button or div with role="combobox" — both respond to .click().
533
+ await triggerLocator.click({ timeout: 2500 }).catch(async () => {
534
+ await triggerLocator.click({ force: true, timeout: 1500 });
535
+ });
536
+ await page.waitForTimeout(300);
537
+
538
+ // Locate the popover. It's appended at document.body and identified by
539
+ // [data-automation-id="activeListContainer"] (Workday's canonical anchor).
540
+ const containerSel = '[data-automation-id="activeListContainer"]';
541
+ const optionSel = '[data-automation-id="promptOption"]';
542
+ try {
543
+ await root.locator(containerSel).first().waitFor({ state: 'visible', timeout: 2200 });
544
+ } catch {
545
+ return { ok: false, reason: 'workday: activeListContainer never appeared' };
546
+ }
547
+
548
+ // If the trigger also accepts text (searchable combobox), type the value
549
+ // to filter the list. Speeds up long-list matching dramatically.
550
+ const typed = String(value).split(/[,;]/)[0].trim();
551
+ try {
552
+ const isTextInput = await triggerLocator.evaluate((el) => {
553
+ const tag = (el.tagName || '').toLowerCase();
554
+ const type = (el.getAttribute('type') || '').toLowerCase();
555
+ return tag === 'input' && (type === 'text' || type === 'search' || type === '');
556
+ }).catch(() => false);
557
+ if (isTextInput && typed) {
558
+ await triggerLocator.fill('').catch(() => {});
559
+ await page.keyboard.type(typed, { delay: 30 });
560
+ await page.waitForTimeout(450); // let virtualized list re-render filtered set
561
+ }
562
+ } catch { /* not a text input — skip filter step */ }
563
+
564
+ // Scroll-and-scrape. Snapshot options, scroll, snapshot, repeat until
565
+ // stable for 2 rounds or we hit the scroll budget.
566
+ const container = root.locator(containerSel).first();
567
+ const seen = new Map(); // text → first index seen
568
+ const MAX_SCROLLS = 30;
569
+ let stableRounds = 0;
570
+ let prevSize = 0;
571
+ for (let i = 0; i < MAX_SCROLLS; i++) {
572
+ const visible = await root.locator(optionSel).evaluateAll((nodes) => {
573
+ return nodes.map((n) => (n.innerText || n.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean);
574
+ }).catch(() => []);
575
+ for (const t of visible) { if (!seen.has(t)) seen.set(t, seen.size); }
576
+ if (seen.size === prevSize) {
577
+ stableRounds += 1;
578
+ if (stableRounds >= 2) break; // list exhausted
579
+ } else {
580
+ stableRounds = 0;
581
+ prevSize = seen.size;
582
+ }
583
+ // Scroll the container to materialize more entries.
584
+ await container.evaluate((el) => { el.scrollTop = el.scrollTop + el.clientHeight; }).catch(() => {});
585
+ await page.waitForTimeout(180);
586
+ }
587
+
588
+ const options = Array.from(seen.keys());
589
+ if (options.length === 0) {
590
+ return { ok: false, reason: 'workday: scrolled but no promptOption text found' };
591
+ }
592
+
593
+ // Fuzzy match: exact → starts-with → contains → token overlap.
594
+ const v = String(value).toLowerCase().trim();
595
+ let pickText = options.find((t) => t.toLowerCase() === v)
596
+ || options.find((t) => t.toLowerCase().startsWith(v))
597
+ || options.find((t) => t.toLowerCase().includes(v) || v.includes(t.toLowerCase()));
598
+ if (!pickText) {
599
+ const vTokens = v.split(/\s+/).filter((t) => t.length > 2);
600
+ pickText = options.find((t) => {
601
+ const tt = t.toLowerCase();
602
+ return vTokens.some((vt) => tt.includes(vt));
603
+ });
604
+ }
605
+ if (!pickText) {
606
+ await page.keyboard.press('Escape').catch(() => {});
607
+ return { ok: false, reason: `workday: no option matched "${value}" (had ${options.length}: ${options.slice(0, 4).join(' / ')}${options.length > 4 ? '…' : ''})` };
608
+ }
609
+
610
+ // Click by visible text — scoped to the popover so we don't accidentally
611
+ // hit a same-named link elsewhere on the page.
612
+ const target = root.locator(`${containerSel} ${optionSel}`).filter({ hasText: pickText }).first();
613
+ await target.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {});
614
+ await target.click({ timeout: 1500 }).catch(async () => {
615
+ await target.click({ force: true, timeout: 1500 });
616
+ });
617
+ await page.waitForTimeout(200);
618
+
619
+ // Verify: the popover should have closed and the trigger should reflect
620
+ // the selection. If neither holds, the click silently failed.
621
+ const stillOpen = await root.locator(containerSel).first().isVisible({ timeout: 400 }).catch(() => false);
622
+ if (stillOpen) {
623
+ // One more try with a keyboard Enter (Workday sometimes needs that on the
624
+ // highlighted option).
625
+ await page.keyboard.press('Enter').catch(() => {});
626
+ await page.waitForTimeout(150);
627
+ }
628
+ return { ok: true, reason: `workday picked: ${pickText}` };
629
+ } catch (e) {
630
+ return { ok: false, reason: `workday option picker threw: ${e.message}` };
631
+ }
632
+ }
633
+
506
634
  async function openAndPickOption(root, triggerLocator, value, llmCtx) {
507
635
  // root: Page or Frame. .locator works on both; keyboard/waitForTimeout
508
636
  // need the Page. For in-frame fields, option menus render inside the
@@ -547,6 +675,26 @@ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
547
675
  }
548
676
  } catch {}
549
677
 
678
+ try {
679
+ // Workday combobox / multi-select. Workday wraps every form widget in a
680
+ // [data-automation-id^="formField-"] container and its dropdowns mount a
681
+ // popover with [data-automation-id="activeListContainer"], whose options
682
+ // are [data-automation-id="promptOption"] entries. Long lists (country,
683
+ // state, university) are virtualized: scrolling triggers more entries to
684
+ // render. We borrow the scroll-and-scrape pattern from the public
685
+ // shanjilcoding/resume-tailor-public/src/lib/workday.js bookmarklet.
686
+ const isWorkdayCombo = await triggerLocator.evaluate((el) => {
687
+ const inside = !!el.closest('[data-automation-id^="formField-"]');
688
+ const onWorkday = /myworkdayjobs\.com|workday\.com/i.test(window.location.href);
689
+ return inside || onWorkday;
690
+ }).catch(() => false);
691
+ if (isWorkdayCombo) {
692
+ const r = await pickWorkdayOption(root, triggerLocator, value);
693
+ if (r && r.ok) return r;
694
+ // If it failed (not actually a Workday combobox after all), fall through.
695
+ }
696
+ } catch {}
697
+
550
698
  // Second: Google Places typeahead. The Greenhouse Location field is
551
699
  // marked role=combobox in AX, so the planner sends click_option — but
552
700
  // it's really a type-then-pick autocomplete that opens a .pac-container
@@ -795,13 +943,33 @@ async function typeAndPickSuggestion(root, locator, value) {
795
943
  * @returns {Promise<{filled, skipped, failed, askUserReasons, planned}>}
796
944
  */
797
945
  async function smartFillPage(page, aep, options) {
798
- const { config, jobId, resumePath, coverLetterPath, ctx } = options;
946
+ const { config, jobId, resumePath, coverLetterPath, ctx, ats } = options;
799
947
 
800
- // 1. Scan via AX tree
801
- const fields = await scanAccessibility(page).catch((e) => {
802
- console.warn(`[smartFill] scanAccessibility failed: ${e.message}`);
803
- return [];
804
- });
948
+ // 1. Scan via AX tree. For Workday, prefer the DOM-walker that targets
949
+ // [data-automation-id^="formField-"] the AX-tree scan returns mostly
950
+ // nothing on Workday's shadow-DOM React, which is what historically forced
951
+ // the entire job into vision (expensive). If scanWorkday returns <5 fields
952
+ // (atypical posting, login wall) we fall through to scanAccessibility so
953
+ // the orchestrator's downstream vision precision-fill still gets a shot.
954
+ const isWorkdayAts = (String(ats || '').toLowerCase() === 'workday')
955
+ || /myworkdayjobs\.com|workday\.com/i.test(page.url());
956
+ let fields = [];
957
+ if (isWorkdayAts) {
958
+ const wd = await scanWorkday(page).catch((e) => {
959
+ console.warn(`[smartFill] scanWorkday failed: ${e.message}`);
960
+ return [];
961
+ });
962
+ if (wd.length >= 5) {
963
+ fields = wd;
964
+ console.log(`[scanWorkday] ${fields.length} fields detected via data-automation-id`);
965
+ }
966
+ }
967
+ if (fields.length === 0) {
968
+ fields = await scanAccessibility(page).catch((e) => {
969
+ console.warn(`[smartFill] scanAccessibility failed: ${e.message}`);
970
+ return [];
971
+ });
972
+ }
805
973
  if (fields.length === 0) {
806
974
  console.warn('[smartFill] no fields detected on page');
807
975
  return { filled: 0, skipped: 0, failed: 0, askUserReasons: [], planned: 0, fallback: false };
@@ -846,6 +1014,30 @@ async function smartFillPage(page, aep, options) {
846
1014
 
847
1015
  console.log(`[smartFill] planner returned ${plan.length} actions for ${fields.length} fields`);
848
1016
 
1017
+ // Phase 4 of the Workday end-to-end fix: dry-run preview, Workday only.
1018
+ // Workday forms are LONG (often 30+ fields across multiple pages) and the
1019
+ // cost of a wrong fill — especially on demographic / sponsorship / clearance
1020
+ // fields where the form rejects misclicks — is high. We render every
1021
+ // planned action as a single readable line and hold 8 seconds before
1022
+ // touching the DOM, letting the user Ctrl-C if anything looks off.
1023
+ // Off-switch: config.workdayDryRun = false in ~/.halo-agent/config.json.
1024
+ // Greenhouse / Lever / Ashby do NOT get this gate — their forms are short
1025
+ // enough that the existing post-fill REVIEWING gate covers it.
1026
+ if (isWorkdayAts && config?.workdayDryRun !== false) {
1027
+ console.log('');
1028
+ console.log(`[dry-run] Workday — about to fill ${plan.length} fields. Ctrl-C in the next 8s to cancel.`);
1029
+ const planByMmidPreview = new Map(plan.map((p) => [String(p.mmid), p]));
1030
+ for (const f of fields) {
1031
+ const it = planByMmidPreview.get(String(f.mmid));
1032
+ if (!it || it.action === 'skip') continue;
1033
+ const labelShort = (f.label || f.selectorHint || '?').slice(0, 50);
1034
+ const valShort = String(it.value || '').slice(0, 60).replace(/\s+/g, ' ');
1035
+ console.log(` · ${it.action.padEnd(13)} ${labelShort.padEnd(50)} ${valShort}`);
1036
+ }
1037
+ console.log('');
1038
+ await page.waitForTimeout(8000);
1039
+ }
1040
+
849
1041
  // 4. Execute the plan in DOM order (planner doesn't dictate order; we
850
1042
  // mirror the field scan order so dependent fields fill after their parents).
851
1043
  const fieldByMmid = new Map(fields.map((f) => [String(f.mmid), f]));