job-forge 2.3.0 → 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.
@@ -307,6 +307,7 @@ When a form says "enter the code we sent to your email", you MUST retrieve the c
307
307
  | Lever | `from:lever newer_than:10m` |
308
308
  | Ashby | `from:ashby newer_than:10m` |
309
309
  | SmartRecruiters | `from:smartrecruiters newer_than:10m` |
310
+ | Toast (via ClinchTalent) | `from:toast.mail.clinchtalent.com newer_than:15m` OR `subject:"verify your login at Toast" newer_than:15m` |
310
311
  | Aggregator redirect (WeWorkRemotely / RemoteOK) | Detect the underlying ATS from the post-redirect URL, then use that row's sender query |
311
312
  | Unknown | `newer_than:10m subject:(verify OR code OR confirm)` |
312
313
 
@@ -314,6 +315,7 @@ When a form says "enter the code we sent to your email", you MUST retrieve the c
314
315
  - ALWAYS check Gmail before reporting a submission as failed.
315
316
  - If "submit button did nothing", it usually means an OTP step appeared. Check Gmail.
316
317
  - If no email after 10 seconds, retry `gmail_list_messages` once more with `newer_than:5m`.
318
+ - **Some Greenhouse tenants route OTP through third-party verification (Toast uses ClinchTalent).** If `from:greenhouse` returns empty after a Greenhouse submit, check the tenant-specific sender row above. Confirmed 2026-04-19: Toast Principal SWE #807 and Toast Senior FE #808.
317
319
 
318
320
  ---
319
321
 
@@ -369,6 +371,24 @@ These blocks come from two distinct root causes and require different responses:
369
371
 
370
372
  **Rule — do NOT loop retrying a class B block.** One retry with `imeFriendly: true` is the correct test for class A. If the same spam message fires after a clean `imeFriendly` refill, stop, mark Failed, move on. Repeated retries waste subagent time and do not change the outcome.
371
373
 
374
+ **Known-block Ashby tenants (2026-04-19 empirical observations).** These tenants fired class B on every attempted submit from a headless datacenter-IP proxy. Orchestrators planning apply dispatches should assume these tenants will Fail in headless — prioritize other portals, or skip same-tenant siblings after a confirmed class B to avoid burning subagent slots:
375
+
376
+ - Vellum, Linear, Vanta, River Financial, Higharc, Trace Labs, Solace Health, Unstructured, ClickUp, Zapier, Deepgram, Ramp, WorkOS
377
+
378
+ **Known class-A-compatible Ashby tenants (same observations).** These tenants accepted headless submits cleanly, often with `imeFriendly: true` making the difference on the text-field subset:
379
+
380
+ - Supabase, LangChain, Poolside, Runway Financial
381
+
382
+ The pattern is tenant configuration, not role or company size. Lists drift as tenants tune their anti-bot — treat as probabilistic priors, not hard rules.
383
+
384
+ ### Greenhouse Bot-Detection Honeypots
385
+
386
+ Some Greenhouse tenants (Grafana Labs confirmed, 2026-04-19) inject a honeypot-style single-pick question on the application form, rendered as a listbox labeled something like "Which of the following best describes you?" with options resembling "I am a human being / I am a bot / I am a robot".
387
+
388
+ **Rule:** pick the "I am a human being" option (or whichever option is the obvious human-authentic choice). Bots that pick other options are filtered before submit. This is NOT a validation check — the field will always read back clean — but the submit will be silently discarded if the wrong option is selected.
389
+
390
+ If the honeypot question is absent, skip. If present, always pick the human option.
391
+
372
392
  ### Nested Scroll Containers (Greenhouse / Ashby)
373
393
 
374
394
  The major ATS portals (Greenhouse, Workday, Lever, Ashby) use nested scrollable regions. A field's `visibleBounds` may show it as off-screen even when it is actually visible within a child scroll container. Geometra's `scroll_to` operates on the outermost page scroll, so it cannot reach fields in inner scroll regions.
package/AGENTS.md CHANGED
@@ -302,6 +302,7 @@ When a form says "enter the code we sent to your email", you MUST retrieve the c
302
302
  | Lever | `from:lever newer_than:10m` |
303
303
  | Ashby | `from:ashby newer_than:10m` |
304
304
  | SmartRecruiters | `from:smartrecruiters newer_than:10m` |
305
+ | Toast (via ClinchTalent) | `from:toast.mail.clinchtalent.com newer_than:15m` OR `subject:"verify your login at Toast" newer_than:15m` |
305
306
  | Aggregator redirect (WeWorkRemotely / RemoteOK) | Detect the underlying ATS from the post-redirect URL, then use that row's sender query |
306
307
  | Unknown | `newer_than:10m subject:(verify OR code OR confirm)` |
307
308
 
@@ -309,6 +310,7 @@ When a form says "enter the code we sent to your email", you MUST retrieve the c
309
310
  - ALWAYS check Gmail before reporting a submission as failed.
310
311
  - If "submit button did nothing", it usually means an OTP step appeared. Check Gmail.
311
312
  - If no email after 10 seconds, retry `gmail_list_messages` once more with `newer_than:5m`.
313
+ - **Some Greenhouse tenants route OTP through third-party verification (Toast uses ClinchTalent).** If `from:greenhouse` returns empty after a Greenhouse submit, check the tenant-specific sender row above. Confirmed 2026-04-19: Toast Principal SWE #807 and Toast Senior FE #808.
312
314
 
313
315
  ---
314
316
 
@@ -364,6 +366,24 @@ These blocks come from two distinct root causes and require different responses:
364
366
 
365
367
  **Rule — do NOT loop retrying a class B block.** One retry with `imeFriendly: true` is the correct test for class A. If the same spam message fires after a clean `imeFriendly` refill, stop, mark Failed, move on. Repeated retries waste subagent time and do not change the outcome.
366
368
 
369
+ **Known-block Ashby tenants (2026-04-19 empirical observations).** These tenants fired class B on every attempted submit from a headless datacenter-IP proxy. Orchestrators planning apply dispatches should assume these tenants will Fail in headless — prioritize other portals, or skip same-tenant siblings after a confirmed class B to avoid burning subagent slots:
370
+
371
+ - Vellum, Linear, Vanta, River Financial, Higharc, Trace Labs, Solace Health, Unstructured, ClickUp, Zapier, Deepgram, Ramp, WorkOS
372
+
373
+ **Known class-A-compatible Ashby tenants (same observations).** These tenants accepted headless submits cleanly, often with `imeFriendly: true` making the difference on the text-field subset:
374
+
375
+ - Supabase, LangChain, Poolside, Runway Financial
376
+
377
+ The pattern is tenant configuration, not role or company size. Lists drift as tenants tune their anti-bot — treat as probabilistic priors, not hard rules.
378
+
379
+ ### Greenhouse Bot-Detection Honeypots
380
+
381
+ Some Greenhouse tenants (Grafana Labs confirmed, 2026-04-19) inject a honeypot-style single-pick question on the application form, rendered as a listbox labeled something like "Which of the following best describes you?" with options resembling "I am a human being / I am a bot / I am a robot".
382
+
383
+ **Rule:** pick the "I am a human being" option (or whichever option is the obvious human-authentic choice). Bots that pick other options are filtered before submit. This is NOT a validation check — the field will always read back clean — but the submit will be silently discarded if the wrong option is selected.
384
+
385
+ If the honeypot question is absent, skip. If present, always pick the human option.
386
+
367
387
  ### Nested Scroll Containers (Greenhouse / Ashby)
368
388
 
369
389
  The major ATS portals (Greenhouse, Workday, Lever, Ashby) use nested scrollable regions. A field's `visibleBounds` may show it as off-screen even when it is actually visible within a child scroll container. Geometra's `scroll_to` operates on the outermost page scroll, so it cannot reach fields in inner scroll regions.
package/CLAUDE.md CHANGED
@@ -302,6 +302,7 @@ When a form says "enter the code we sent to your email", you MUST retrieve the c
302
302
  | Lever | `from:lever newer_than:10m` |
303
303
  | Ashby | `from:ashby newer_than:10m` |
304
304
  | SmartRecruiters | `from:smartrecruiters newer_than:10m` |
305
+ | Toast (via ClinchTalent) | `from:toast.mail.clinchtalent.com newer_than:15m` OR `subject:"verify your login at Toast" newer_than:15m` |
305
306
  | Aggregator redirect (WeWorkRemotely / RemoteOK) | Detect the underlying ATS from the post-redirect URL, then use that row's sender query |
306
307
  | Unknown | `newer_than:10m subject:(verify OR code OR confirm)` |
307
308
 
@@ -309,6 +310,7 @@ When a form says "enter the code we sent to your email", you MUST retrieve the c
309
310
  - ALWAYS check Gmail before reporting a submission as failed.
310
311
  - If "submit button did nothing", it usually means an OTP step appeared. Check Gmail.
311
312
  - If no email after 10 seconds, retry `gmail_list_messages` once more with `newer_than:5m`.
313
+ - **Some Greenhouse tenants route OTP through third-party verification (Toast uses ClinchTalent).** If `from:greenhouse` returns empty after a Greenhouse submit, check the tenant-specific sender row above. Confirmed 2026-04-19: Toast Principal SWE #807 and Toast Senior FE #808.
312
314
 
313
315
  ---
314
316
 
@@ -364,6 +366,24 @@ These blocks come from two distinct root causes and require different responses:
364
366
 
365
367
  **Rule — do NOT loop retrying a class B block.** One retry with `imeFriendly: true` is the correct test for class A. If the same spam message fires after a clean `imeFriendly` refill, stop, mark Failed, move on. Repeated retries waste subagent time and do not change the outcome.
366
368
 
369
+ **Known-block Ashby tenants (2026-04-19 empirical observations).** These tenants fired class B on every attempted submit from a headless datacenter-IP proxy. Orchestrators planning apply dispatches should assume these tenants will Fail in headless — prioritize other portals, or skip same-tenant siblings after a confirmed class B to avoid burning subagent slots:
370
+
371
+ - Vellum, Linear, Vanta, River Financial, Higharc, Trace Labs, Solace Health, Unstructured, ClickUp, Zapier, Deepgram, Ramp, WorkOS
372
+
373
+ **Known class-A-compatible Ashby tenants (same observations).** These tenants accepted headless submits cleanly, often with `imeFriendly: true` making the difference on the text-field subset:
374
+
375
+ - Supabase, LangChain, Poolside, Runway Financial
376
+
377
+ The pattern is tenant configuration, not role or company size. Lists drift as tenants tune their anti-bot — treat as probabilistic priors, not hard rules.
378
+
379
+ ### Greenhouse Bot-Detection Honeypots
380
+
381
+ Some Greenhouse tenants (Grafana Labs confirmed, 2026-04-19) inject a honeypot-style single-pick question on the application form, rendered as a listbox labeled something like "Which of the following best describes you?" with options resembling "I am a human being / I am a bot / I am a robot".
382
+
383
+ **Rule:** pick the "I am a human being" option (or whichever option is the obvious human-authentic choice). Bots that pick other options are filtered before submit. This is NOT a validation check — the field will always read back clean — but the submit will be silently discarded if the wrong option is selected.
384
+
385
+ If the honeypot question is absent, skip. If present, always pick the human option.
386
+
367
387
  ### Nested Scroll Containers (Greenhouse / Ashby)
368
388
 
369
389
  The major ATS portals (Greenhouse, Workday, Lever, Ashby) use nested scrollable regions. A field's `visibleBounds` may show it as off-screen even when it is actually visible within a child scroll container. Geometra's `scroll_to` operates on the outermost page scroll, so it cannot reach fields in inner scroll regions.
@@ -302,6 +302,7 @@ When a form says "enter the code we sent to your email", you MUST retrieve the c
302
302
  | Lever | `from:lever newer_than:10m` |
303
303
  | Ashby | `from:ashby newer_than:10m` |
304
304
  | SmartRecruiters | `from:smartrecruiters newer_than:10m` |
305
+ | Toast (via ClinchTalent) | `from:toast.mail.clinchtalent.com newer_than:15m` OR `subject:"verify your login at Toast" newer_than:15m` |
305
306
  | Aggregator redirect (WeWorkRemotely / RemoteOK) | Detect the underlying ATS from the post-redirect URL, then use that row's sender query |
306
307
  | Unknown | `newer_than:10m subject:(verify OR code OR confirm)` |
307
308
 
@@ -309,6 +310,7 @@ When a form says "enter the code we sent to your email", you MUST retrieve the c
309
310
  - ALWAYS check Gmail before reporting a submission as failed.
310
311
  - If "submit button did nothing", it usually means an OTP step appeared. Check Gmail.
311
312
  - If no email after 10 seconds, retry `gmail_list_messages` once more with `newer_than:5m`.
313
+ - **Some Greenhouse tenants route OTP through third-party verification (Toast uses ClinchTalent).** If `from:greenhouse` returns empty after a Greenhouse submit, check the tenant-specific sender row above. Confirmed 2026-04-19: Toast Principal SWE #807 and Toast Senior FE #808.
312
314
 
313
315
  ---
314
316
 
@@ -364,6 +366,24 @@ These blocks come from two distinct root causes and require different responses:
364
366
 
365
367
  **Rule — do NOT loop retrying a class B block.** One retry with `imeFriendly: true` is the correct test for class A. If the same spam message fires after a clean `imeFriendly` refill, stop, mark Failed, move on. Repeated retries waste subagent time and do not change the outcome.
366
368
 
369
+ **Known-block Ashby tenants (2026-04-19 empirical observations).** These tenants fired class B on every attempted submit from a headless datacenter-IP proxy. Orchestrators planning apply dispatches should assume these tenants will Fail in headless — prioritize other portals, or skip same-tenant siblings after a confirmed class B to avoid burning subagent slots:
370
+
371
+ - Vellum, Linear, Vanta, River Financial, Higharc, Trace Labs, Solace Health, Unstructured, ClickUp, Zapier, Deepgram, Ramp, WorkOS
372
+
373
+ **Known class-A-compatible Ashby tenants (same observations).** These tenants accepted headless submits cleanly, often with `imeFriendly: true` making the difference on the text-field subset:
374
+
375
+ - Supabase, LangChain, Poolside, Runway Financial
376
+
377
+ The pattern is tenant configuration, not role or company size. Lists drift as tenants tune their anti-bot — treat as probabilistic priors, not hard rules.
378
+
379
+ ### Greenhouse Bot-Detection Honeypots
380
+
381
+ Some Greenhouse tenants (Grafana Labs confirmed, 2026-04-19) inject a honeypot-style single-pick question on the application form, rendered as a listbox labeled something like "Which of the following best describes you?" with options resembling "I am a human being / I am a bot / I am a robot".
382
+
383
+ **Rule:** pick the "I am a human being" option (or whichever option is the obvious human-authentic choice). Bots that pick other options are filtered before submit. This is NOT a validation check — the field will always read back clean — but the submit will be silently discarded if the wrong option is selected.
384
+
385
+ If the honeypot question is absent, skip. If present, always pick the human option.
386
+
367
387
  ### Nested Scroll Containers (Greenhouse / Ashby)
368
388
 
369
389
  The major ATS portals (Greenhouse, Workday, Lever, Ashby) use nested scrollable regions. A field's `visibleBounds` may show it as off-screen even when it is actually visible within a child scroll container. Geometra's `scroll_to` operates on the outermost page scroll, so it cannot reach fields in inner scroll regions.
package/merge-tracker.mjs CHANGED
@@ -60,6 +60,29 @@ Run from the repository root.`);
60
60
  const CANONICAL_STATES = loadCanonicalStates(PROJECT_DIR) || DEFAULT_STATES;
61
61
  const STATUS_DETECT_RE = buildStatusDetectionRegex(CANONICAL_STATES);
62
62
 
63
+ // Lifecycle precedence — higher value means the status represents a later
64
+ // stage of the application and should override an earlier stage on merge,
65
+ // independent of score. Evaluated (pure eval, no action) is the baseline;
66
+ // any action state outranks it. This fixes a historical bug where a higher-
67
+ // score Evaluated row would silently block an Applied/Failed/SKIP outcome
68
+ // from propagating because the merge considered score alone.
69
+ const STATUS_PRECEDENCE = {
70
+ 'Evaluated': 0,
71
+ 'SKIP': 1,
72
+ 'Discarded': 1,
73
+ 'Contacted': 2,
74
+ 'Failed': 2,
75
+ 'Applied': 3,
76
+ 'Responded': 4,
77
+ 'Rejected': 4,
78
+ 'Interview': 5,
79
+ 'Offer': 6,
80
+ };
81
+
82
+ function statusRank(s) {
83
+ return STATUS_PRECEDENCE[s] ?? 0;
84
+ }
85
+
63
86
  function validateStatus(status) {
64
87
  const clean = status.replace(/\*\*/g, '').replace(/\s+\d{4}-\d{2}-\d{2}.*$/, '').trim();
65
88
  const lower = clean.toLowerCase();
@@ -274,25 +297,49 @@ for (const file of tsvFiles) {
274
297
  if (duplicate) {
275
298
  const newScore = parseScore(addition.score);
276
299
  const oldScore = parseScore(duplicate.score);
277
-
278
- if (newScore > oldScore) {
279
- console.log(`🔄 Update: #${duplicate.num} ${addition.company} — ${addition.role} (${oldScore}→${newScore})`);
300
+ const newRank = statusRank(addition.status);
301
+ const oldRank = statusRank(duplicate.status);
302
+
303
+ // Update if EITHER the lifecycle status advances (e.g. Evaluated → Applied)
304
+ // OR the score improves. Never regress the status (Applied → Evaluated is
305
+ // ignored). Same-rank same-score updates are skipped as no-op.
306
+ const statusAdvances = newRank > oldRank;
307
+ const statusRegresses = newRank < oldRank;
308
+ const scoreImproves = newScore > oldScore;
309
+
310
+ if (statusAdvances || (!statusRegresses && scoreImproves)) {
311
+ const newStatus = statusAdvances ? addition.status : duplicate.status;
312
+ const newPdf = statusAdvances ? addition.pdf : duplicate.pdf;
313
+ const reason = statusAdvances
314
+ ? `${duplicate.status}→${newStatus}`
315
+ : `${oldScore}→${newScore}`;
316
+ console.log(`🔄 Update: #${duplicate.num} ${addition.company} — ${addition.role} (${reason})`);
280
317
 
281
318
  if (layout === 'day') {
282
- // Update in existing entries list for later write
283
319
  duplicate.date = addition.date;
284
320
  duplicate.company = addition.company;
285
321
  duplicate.role = addition.role;
286
- duplicate.score = addition.score;
322
+ duplicate.score = scoreImproves ? addition.score : duplicate.score;
323
+ duplicate.status = newStatus;
324
+ duplicate.pdf = newPdf;
287
325
  duplicate.report = addition.report;
288
- duplicate.notes = `Re-eval ${addition.date} (${oldScore}→${newScore}). ${addition.notes}`;
326
+ duplicate.notes = statusAdvances
327
+ ? addition.notes
328
+ : `Re-eval ${addition.date} (${oldScore}→${newScore}). ${addition.notes}`;
289
329
  } else {
290
330
  const lineIdx = appLines.indexOf(duplicate.raw);
331
+ const outScore = scoreImproves ? addition.score : duplicate.score;
332
+ const noteText = statusAdvances
333
+ ? addition.notes
334
+ : `Re-eval ${addition.date} (${oldScore}→${newScore}). ${addition.notes}`;
291
335
  if (lineIdx >= 0) {
292
- appLines[lineIdx] = `| ${duplicate.num} | ${addition.date} | ${addition.company} | ${addition.role} | ${addition.score} | ${duplicate.status} | ${duplicate.pdf} | ${addition.report} | Re-eval ${addition.date} (${oldScore}→${newScore}). ${addition.notes} |`;
336
+ appLines[lineIdx] = `| ${duplicate.num} | ${addition.date} | ${addition.company} | ${addition.role} | ${outScore} | ${newStatus} | ${newPdf} | ${addition.report} | ${noteText} |`;
293
337
  }
294
338
  }
295
339
  updated++;
340
+ } else if (statusRegresses) {
341
+ console.log(`⏭️ Skip: ${addition.company} — ${addition.role} (existing #${duplicate.num} status ${duplicate.status} outranks new ${addition.status})`);
342
+ skipped++;
296
343
  } else {
297
344
  console.log(`⏭️ Skip: ${addition.company} — ${addition.role} (existing #${duplicate.num} ${oldScore} >= new ${newScore})`);
298
345
  skipped++;
package/modes/apply.md CHANGED
@@ -260,6 +260,22 @@ If you've uploaded a file with a dedicated `geometra_run_actions` call (e.g., th
260
260
 
261
261
  Specific portals — Workday "parse my resume", iCIMS multi-step, SAP SuccessFactors — reveal additional fields ONLY after a file upload. In that case, use exactly two `run_actions` calls: (1) upload + wait_for, (2) fill+submit. After the first call, call `geometra_form_schema` **once** to discover the newly-revealed labels, then run the second call using labels. Never more than two phases.
262
262
 
263
+ ### Resume-upload silent-fail → chooser-strategy fallback (Greenhouse)
264
+
265
+ Some Greenhouse tenants (Grafana Labs confirmed, 2026-04-19) render the resume upload as a file input where the default `upload_files` action readback succeeds but the field stays empty — Submit returns "Resume/CV is required." only after submit is clicked.
266
+
267
+ **Fix:** if the resume field shows empty after an `upload_files` action (either by explicit readback or by a "Resume/CV is required" error post-submit), re-upload using `strategy: chooser` with x,y coordinates pulled from the upload button's `visibleBounds` center. Example:
268
+
269
+ ```
270
+ { type: "upload_files",
271
+ fieldLabel: "Resume/CV",
272
+ paths: ["/abs/path/cv.pdf"],
273
+ strategy: "chooser",
274
+ x: 314, y: 474 }
275
+ ```
276
+
277
+ The `chooser` strategy triggers the native file picker via click-at-coordinates, which bypasses the React-controlled input that silently drops programmatic assignments on some Greenhouse tenants. One retry is enough; if it still fails, mark Failed.
278
+
263
279
  ## Step 6 — Resolve OTP verification (if prompted)
264
280
 
265
281
  Check for an OTP gate after the candidate (or Geometra) submits — the major portals (Greenhouse, Workday, Lever, Ashby) gate submission behind an email verification code. When an OTP step appears, do this.
@@ -282,6 +298,7 @@ Check for an OTP gate after the candidate (or Geometra) submits — the major po
282
298
  | `smartrecruiters` | `from:smartrecruiters newer_than:10m` |
283
299
  | `wwr` / `remoteok` | Follow the apply redirect to the underlying ATS, re-detect the host, then use that row's query. Aggregators do not send OTP emails themselves. |
284
300
  | `builtin` | `from:builtin newer_than:10m` |
301
+ | Toast (via Greenhouse + ClinchTalent) | `from:toast.mail.clinchtalent.com newer_than:15m` OR `subject:"verify your login at Toast" newer_than:15m`. Default `from:greenhouse` returns null — Toast routes OTP through ClinchTalent. |
285
302
  | `custom` / `unknown` / missing | `newer_than:10m subject:(verify OR code OR confirm)` |
286
303
 
287
304
  **Fallback when `ats` is missing** (legacy pipeline entries with no `| ats=` suffix, or scan-output without an `ats` column): infer from the URL host — `*.greenhouse.io` → `greenhouse`; `jobs.ashbyhq.com` → `ashby`; `jobs.lever.co` → `lever`; `*.myworkdayjobs.com` → `workday`; `apply.workable.com` / `jobs.workable.com` → `workable`; `api.smartrecruiters.com` / `jobs.smartrecruiters.com` → `smartrecruiters`; `weworkremotely.com` → `wwr`; `remoteok.com` → `remoteok`; `builtin.com` → `builtin`; otherwise use the generic `verify OR code OR confirm` subject query.
package/modes/scan.md CHANGED
@@ -45,7 +45,7 @@ Supported API shapes:
45
45
  - **Endpoint**: `https://boards-api.greenhouse.io/v1/boards/{slug}/jobs`
46
46
  - **Method**: `GET` (plain, no auth)
47
47
  - **Shape**: `{ jobs: [{ id, title, absolute_url, updated_at, location: { name } }, ...] }`
48
- - **Canonical URL to record**: `https://job-boards.greenhouse.io/{slug}/jobs/{id}` — do NOT use `absolute_url` when it points to a customer-skinned front-end (see Verification section below).
48
+ - **Canonical URL to record**: `https://job-boards.greenhouse.io/{slug}/jobs/{id}` — do NOT use `absolute_url` when it points to a customer-skinned front-end (see **Verify Before Marking CLOSED** below).
49
49
  - **ats**: `greenhouse`
50
50
 
51
51
  #### Ashby (JSON, per-company board)
@@ -75,7 +75,7 @@ Supported API shapes:
75
75
  ```json
76
76
  {"appliedFacets": {}, "limit": 20, "offset": 0, "searchText": ""}
77
77
  ```
78
- - **Required headers**: `Content-Type: application/json`, `Accept: application/json`. Some tenants reject requests without a realistic `User-Agent` — set one if the response is 403.
78
+ - **Required headers**: `Content-Type: application/json`, `Accept: application/json`. If the response is 403, set a realistic `User-Agent` header and retry Workday tenants selectively block data-center UAs.
79
79
  - **Shape**: `{ jobPostings: [{ title, externalPath, postedOn, locationsText, bulletFields }, ...], total }`
80
80
  - **Canonical URL to record**: `https://{subdomain}.{pod}.myworkdayjobs.com/{site}{externalPath}` (note: `externalPath` already starts with `/job/...` — do NOT prepend an extra `/`).
81
81
  - **Pagination**: increment `offset` by `limit` (20) until `jobPostings.length < limit` or `offset >= total`.
@@ -287,7 +287,7 @@ NEXT STEP RECOMMENDATION:
287
287
 
288
288
  ## Verify Before Marking CLOSED (downstream rule)
289
289
 
290
- **DO NOT mark a Greenhouse offer CLOSED based on a WebFetch/Geometra result alone.** Customer-skinned careers pages (`pinterestcareers.com`, `okta.com`, `samsara.com`, `zoominfo.com`, `collibra.com`, `careers.toasttab.com`, `careers.airbnb.com`, `coinbase.com`, `instacart.careers`, etc.) serve bot-hostile shells a 403, a navbar-only response, or a client-side-only render. WebFetch sees "no JD" and mis-classifies as CLOSED.
290
+ **DO NOT mark a Greenhouse offer CLOSED based on a WebFetch/Geometra result alone.** Customer-skinned careers pages serve bot-hostile shells — a 403, a navbar-only response, or a client-side-only render — and WebFetch sees "no JD" and mis-classifies as CLOSED. Known customer-skinned hosts: `pinterestcareers.com`, `okta.com`, `samsara.com`, `zoominfo.com`, `collibra.com`, `careers.toasttab.com`, `careers.airbnb.com`, `coinbase.com`, `instacart.careers`. Treat any host that is NOT `greenhouse.io` / `job-boards.greenhouse.io` / `boards-api.greenhouse.io` as customer-skinned.
291
291
 
292
292
  **Correct verification order for any Greenhouse-sourced URL** (identified by a `| gh={slug}/{id}` suffix in `pipeline.md` or a `boards-api.greenhouse.io` / `job-boards.greenhouse.io` / `boards.greenhouse.io` host):
293
293
 
@@ -298,7 +298,7 @@ NEXT STEP RECOMMENDATION:
298
298
  2. Only then fall back to WebFetch of the canonical `job-boards.greenhouse.io/{slug}/jobs/{id}` URL.
299
299
  3. Only then fall back to Geometra on the same canonical URL.
300
300
 
301
- **Rule of thumb:** Greenhouse postings with valid `gh_slug`/`gh_id` should be verified via the API first. A WebFetch failure on a customer-skinned domain is NOT evidence the role is closed.
301
+ **Rule:** Greenhouse postings with valid `gh_slug`/`gh_id` MUST be verified via the API first. A WebFetch failure on a customer-skinned domain is NOT evidence the role is closed.
302
302
 
303
303
  ## Update careers_url
304
304
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-forge",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "AI-powered job search pipeline built on opencode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,8 +18,8 @@
18
18
  "tokens": "node scripts/token-usage-report.mjs",
19
19
  "tokens:today": "node scripts/token-usage-report.mjs --days 1",
20
20
  "tokens:log": "node scripts/token-usage-report.mjs --days 1 --append",
21
- "build:config": "iso-harness build --source iso --out .",
22
- "prepack": "iso-harness build --source iso --out .",
21
+ "build:config": "iso build .",
22
+ "prepack": "iso build .",
23
23
  "release:check-source": "node ./scripts/release/check-source.mjs",
24
24
  "postinstall": "node bin/sync.mjs"
25
25
  },
@@ -74,6 +74,7 @@
74
74
  "playwright": "^1.58.1"
75
75
  },
76
76
  "devDependencies": {
77
+ "@razroo/iso": "^0.1.1",
77
78
  "@razroo/iso-harness": "^0.1.3"
78
79
  }
79
80
  }