diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..0e73a6a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,54 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run packaging tests
+ run: npm test
+
+ - name: Run installed skill tests
+ run: npm run test:skills
+
+ - name: Release-safety gate
+ run: node scripts/check-release-safety.mjs
+
+ - name: Skill-closure verification
+ run: node scripts/verify-skill-closure.mjs
+
+ - name: Dependency-matrix verification
+ run: node scripts/verify-dependency-matrix.mjs
+
+ - name: Validate compose.yaml
+ run: docker compose config --quiet
+
+ - name: Check doc commands
+ run: node scripts/check-doc-commands.mjs
+
+ - name: Check required doc sections
+ run: node scripts/check-required-doc-sections.mjs
+
+ compose-rehearsal:
+ runs-on: ubuntu-latest
+ needs: validate
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Run compose rehearsal
+ run: bash scripts/rehearse-compose.sh
+ timeout-minutes: 10
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..b2c5c3f
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,91 @@
+name: Release
+
+on:
+ push:
+ tags: ['v*']
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ outputs:
+ archive: ${{ steps.archive.outputs.path }}
+ checksum: ${{ steps.checksum.outputs.sha256 }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run packaging tests
+ run: npm test
+
+ - name: Run installed skill tests
+ run: npm run test:skills
+
+ - name: Release verification (source tree)
+ run: npm run verify:release
+
+ - name: Build source archive
+ id: archive
+ run: |
+ ARCHIVE="job-hunter-${GITHUB_REF_NAME}.tar.gz"
+ git archive --prefix="job-hunter-${GITHUB_REF_NAME}/" -o "$ARCHIVE" HEAD
+ echo "path=$ARCHIVE" >> "$GITHUB_OUTPUT"
+
+ - name: SHA-256 checksum
+ id: checksum
+ run: |
+ ARCHIVE="${{ steps.archive.outputs.path }}"
+ SHA=$(sha256sum "$ARCHIVE" | awk '{print $1}')
+ echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
+ echo "$SHA $ARCHIVE" > "${ARCHIVE}.sha256"
+
+ - name: Extract and safety-scan archive
+ run: |
+ ARCHIVE="${{ steps.archive.outputs.path }}"
+ mkdir /tmp/archive-scan
+ tar -xzf "$ARCHIVE" -C /tmp/archive-scan
+ node scripts/check-release-safety.mjs /tmp/archive-scan/job-hunter-${GITHUB_REF_NAME}
+
+ - name: Upload archive
+ uses: actions/upload-artifact@v4
+ with:
+ name: source-archive
+ path: ${{ steps.archive.outputs.path }}
+
+ - name: Upload checksum
+ uses: actions/upload-artifact@v4
+ with:
+ name: source-archive-sha256
+ path: ${{ steps.archive.outputs.path }}.sha256
+
+ release:
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Download archive
+ uses: actions/download-artifact@v4
+ with:
+ name: source-archive
+
+ - name: Download checksum
+ uses: actions/download-artifact@v4
+ with:
+ name: source-archive-sha256
+
+ - name: Create GitHub release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: |
+ *.tar.gz
+ *.sha256
+ draft: true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f4c63f7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,61 @@
+# Dependencies
+node_modules/
+
+# Python
+__pycache__/
+*.pyc
+.venv/
+uv.lock
+
+# Runtime artifacts
+*.sqlite
+*.sqlite3
+*.docx
+*.pdf
+*.xlsx
+*.csv
+!examples/**/*.docx
+personal-info-cache.json
+apply_logs/
+logs/
+backups/
+screenshots/
+*.png
+*.jpg
+*.jpeg
+*.webp
+*.log
+
+# Browser state
+chromium-profile/
+chromium_profile/
+selenium_chrome_profile/
+searxng/
+*.bak
+
+# OS
+.DS_Store
+Thumbs.db
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# Environment
+.env
+.env.local
+.env.*.local
+
+# Job-hunter runtime (never tracked)
+.job-hunter/
+
+# Agent evidence (local, never published)
+agent-output/
+
+# Build/release
+dist/
+tmp/
+*.tar.gz
+*.zip
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..b614b63
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025-2026 Deviad
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f4f4d95
--- /dev/null
+++ b/README.md
@@ -0,0 +1,125 @@
+# Job Hunter
+
+Complete job-search pipeline for [Pi Agent](https://github.com/earendil-works/pi) — search, score, salary enrichment, and assisted applications.
+
+## Prerequisites
+
+| Layer | Requirement | Stage |
+|-------|-------------|-------|
+| Runtime | Node.js ≥ 22, `uv` ≥ 0.4 | Install |
+| Code | `npm ci` in repo root | Install |
+| Workspace | `$JOBHUNTER_HOME` (default `~/.job-hunter`) with `jobhunter.sqlite`, `CV.docx`, `personal-info-cache.json` | Init |
+| Browser | Chromium CDP on `127.0.0.1:9225` (Selenium container) | Search, Apply |
+| Search | SearXNG on `127.0.0.1:8888` | Discover |
+| Vision (optional) | LM Studio on `127.0.0.1:1234` with Qwen VLM | CAPTCHA, Debug |
+| MCP (optional) | Obscura, Apple Mail MCP servers | Browse, Outreach |
+
+Host tools (`docker`, `sqlite3`, `python3`) must be installed and on `PATH` — the doctor (`jh-doctor`) diagnoses which are missing but does not install them.
+
+## Installation
+
+```bash
+git clone https://github.com/Deviad/job-hunter.git && cd job-hunter
+npm ci
+node scripts/install.mjs # copies skills into ${PI_AGENT_HOME:-~/.pi/agent}/skills/
+node scripts/install.mjs --dry-run # preview without writing
+```
+
+Re-run is idempotent and never overwrites `CV.docx`, `personal-info-cache.json`, or `jobhunter.sqlite`.
+
+## Initialization
+
+```bash
+node ~/.pi/agent/skills/job-hunter/scripts/jh-init.mjs
+```
+
+Before applying, populate `~/.job-hunter/CV.docx` and the `profile.firstName`, `profile.lastName`, `profile.email`, and `profile.phone` fields in `~/.job-hunter/personal-info-cache.json`. Application helpers fail closed when required identity fields are absent; the repository contains no maintainer identity defaults.
+
+### Doctor
+
+Run the doctor before each search or application session. It distinguishes required failures from optional degraded capabilities.
+
+```bash
+node ~/.pi/agent/skills/job-hunter/scripts/jh-doctor.mjs
+```
+
+## External Services
+
+Start Selenium and SearXNG:
+
+```bash
+export SEARXNG_SECRET="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))")"
+docker compose up -d
+bash scripts/rehearse-compose.sh # isolated health-check rehearsal with teardown
+```
+
+See `docs/prerequisites.md` for full host-tool list and `docs/security-and-privacy.md` for security boundaries.
+
+## Workflow
+
+### Search
+
+```bash
+node ~/.pi/agent/skills/job-hunter/scripts/jh-search.mjs --source linkedin --country GB --role "Software Engineer"
+node ~/.pi/agent/skills/job-hunter/scripts/jh-search.mjs --source indeed --country GB --query "Software Engineer"
+node ~/.pi/agent/skills/job-hunter/scripts/jh-discover.mjs --locations "United Kingdom" --queries "Software Engineer"
+```
+
+### Score
+
+Ask Pi to score the saved jobs. The `job-match-scorer` skill reads the user's CV and workspace profile rather than repository defaults.
+
+### Salary
+
+```bash
+node ~/.pi/agent/skills/salary-calculator/scripts/enrich-job-salary.mjs --db ~/.job-hunter/jobhunter.sqlite --all-unsalaried
+```
+
+### Apply
+
+Ask Pi to apply to a scored job. The `auto-job-application` skill uses the user's local CV, cache, and authenticated Chromium session; there is no repository-level submit command.
+
+### Pipeline status and backup
+
+```bash
+node ~/.pi/agent/skills/job-hunter/scripts/jh-status.mjs
+node ~/.pi/agent/skills/job-hunter/scripts/jh-backup.mjs
+```
+
+## Update
+
+```bash
+git pull && npm ci && node scripts/install.mjs
+```
+
+## Uninstall
+
+```bash
+node scripts/install.mjs --uninstall
+```
+
+Removes only files recorded in the installation manifest. User data (`CV.docx`, DB, cache) is untouched.
+
+## Troubleshooting
+
+```bash
+node ~/.pi/agent/skills/job-hunter/scripts/jh-doctor.mjs # full diagnostic
+```
+
+Common issues:
+
+- **CDP not reachable**: ensure `docker compose up -d` ran and `curl -s http://127.0.0.1:9225/json/version` returns JSON.
+- **SearXNG 502**: SearXNG needs a few seconds after container start; retry after 5s.
+- **better-sqlite3 build failure**: ensure `python3` and a C compiler (Xcode CLT on macOS) are installed.
+- **MCP "Not connected"**: run the repair skill or `bash scripts/rehearse-compose.sh` to verify transport.
+
+## Security and Privacy
+
+- All job-search data, credentials, and browser sessions stay on your machine.
+- The installer never reads or transmits personal files.
+- Authenticated browser sessions (LinkedIn, Indeed) belong to you — this project does not bypass CAPTCHA, MFA, or site access controls.
+- See `docs/security-and-privacy.md` for the full policy.
+
+## License
+
+MIT — see `LICENSE`.
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..5a35d8c
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,72 @@
+# Job Hunter — Selenium Chromium + SearXNG
+#
+# Usage:
+# docker compose up -d # start services
+# docker compose down # stop (no data loss)
+# bash scripts/rehearse-compose.sh # bounded health check + teardown
+#
+# Prerequisites: Docker with Compose support.
+# No credentials are embedded. Authenticated browser sessions are user-managed.
+
+services:
+ selenium-chromium:
+ image: selenium/standalone-chromium:4.44@sha256:8c5a8629c96104c0d73df94c6437af9ab9059c4e16aa32e35b330b7d77defe0b
+ shm_size: "2gb"
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:${SELENIUM_PORT:-4444}:4444" # Selenium WebDriver
+ - "127.0.0.1:${NOVNC_PORT:-7900}:7900" # noVNC web UI
+ - "127.0.0.1:${CDP_PORT:-9225}:9222" # Chrome DevTools Protocol (CDP)
+ entrypoint:
+ - "/bin/sh"
+ - "-c"
+ - |
+ # Ensure xdotool is available for visual-click-recovery workflow.
+ if ! command -v xdotool >/dev/null 2>&1; then
+ (sudo apt-get update -qq && sudo apt-get install -y -qq xdotool) \
+ || echo "WARN: xdotool install failed; visual click recovery degraded"
+ fi
+ exec /opt/bin/entry_point.sh
+ environment:
+ - SE_START_XVFB=true
+ - SE_START_VNC=true
+ - SE_START_NO_VNC=true
+ - SE_VNC_NO_PASSWORD=true
+ - SE_NO_VNC_PORT=7900
+ - SE_VNC_PORT=5900
+ - SE_SCREEN_WIDTH=1920
+ - SE_SCREEN_HEIGHT=1080
+ volumes:
+ - "${JOBHUNTER_HOME:-${HOME}/.job-hunter}:/home/seluser/job-hunter"
+ - "${SELENIUM_PROFILE_DIR:-${JOBHUNTER_HOME:-${HOME}/.job-hunter}/chromium-profile}:/home/seluser/.config/google-chrome"
+ - "./docker/selenium/cdp_proxy.py:/opt/selenium/cdp_proxy.py:ro"
+ - "./docker/selenium/manual-chrome.conf:/etc/supervisor/conf.d/manual-chrome.conf:ro"
+ networks:
+ - jobhunter
+
+ searxng:
+ image: searxng/searxng:latest@sha256:b36af7984b87191b595bc5301418ed6432c047668a4547ab531a7439b816fac3
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:${SEARXNG_PORT:-8888}:8080"
+ environment:
+ SEARXNG_BASE_URL: http://localhost:${SEARXNG_PORT:-8888}/
+ SEARXNG_LIMITER: "false"
+ SEARXNG_SECRET: ${SEARXNG_SECRET:-}
+ cap_drop:
+ - ALL
+ cap_add:
+ - CHOWN
+ - SETGID
+ - SETUID
+ volumes:
+ - "${JOBHUNTER_HOME:-${HOME}/.job-hunter}/searxng:/etc/searxng"
+ tmpfs:
+ - /tmp
+ networks:
+ - jobhunter
+
+networks:
+ jobhunter:
+ name: jobhunter
+ driver: bridge
diff --git a/docker/selenium/cdp_proxy.py b/docker/selenium/cdp_proxy.py
new file mode 100644
index 0000000..5a0311a
--- /dev/null
+++ b/docker/selenium/cdp_proxy.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+import select
+import socket
+import threading
+
+LISTEN_HOST = "0.0.0.0"
+LISTEN_PORT = 9222
+TARGET_HOST = "127.0.0.1"
+TARGET_PORT = 9223
+BUFFER_SIZE = 65536
+
+
+def pipe(client):
+ target = socket.create_connection((TARGET_HOST, TARGET_PORT))
+ sockets = [client, target]
+ try:
+ while True:
+ readable, _, _ = select.select(sockets, [], [])
+ for source in readable:
+ data = source.recv(BUFFER_SIZE)
+ if not data:
+ return
+ destination = target if source is client else client
+ destination.sendall(data)
+ finally:
+ client.close()
+ target.close()
+
+
+def main():
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ listener.bind((LISTEN_HOST, LISTEN_PORT))
+ listener.listen(50)
+ while True:
+ client, _ = listener.accept()
+ threading.Thread(target=pipe, args=(client,), daemon=True).start()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docker/selenium/manual-chrome.conf b/docker/selenium/manual-chrome.conf
new file mode 100644
index 0000000..3f13357
--- /dev/null
+++ b/docker/selenium/manual-chrome.conf
@@ -0,0 +1,27 @@
+[program:manual-chrome]
+priority=16
+command=bash -lc "sleep 3; exec /usr/lib/chromium/chromium --no-sandbox --disable-dev-shm-usage --remote-debugging-address=127.0.0.1 --remote-debugging-port=9223 --no-first-run --no-default-browser-check --user-data-dir=/home/seluser/.config/google-chrome about:blank"
+autostart=true
+autorestart=true
+startsecs=3
+startretries=5
+stopsignal=TERM
+stopasgroup=true
+killasgroup=true
+redirect_stderr=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+
+[program:cdp-proxy]
+priority=17
+command=python3 /opt/selenium/cdp_proxy.py
+autostart=true
+autorestart=true
+startsecs=0
+startretries=5
+stopsignal=TERM
+stopasgroup=true
+killasgroup=true
+redirect_stderr=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
diff --git a/docs/dependency-matrix.md b/docs/dependency-matrix.md
new file mode 100644
index 0000000..86caad2
--- /dev/null
+++ b/docs/dependency-matrix.md
@@ -0,0 +1,81 @@
+# Dependency Matrix
+
+This matrix covers every bundled skill and classifies code dependencies, executables, services, MCP integrations, and workflow-stage optionality. **Required** means the named workflow cannot run without it. **Conditional** means only specific stages need it. **Optional** means its absence degrades a recovery or convenience path.
+
+## Skills and Packages
+
+| Skill | Class | Node packages | Python dependencies | Direct skill dependencies |
+|---|---|---|---|---|
+| `job-hunter` | Core | `better-sqlite3`, `ws` | None | LinkedIn, Indeed, scorer, salary, auto-apply |
+| `linkedin-job-search` | Core | `ws`, workspace `better-sqlite3` | None | CAPTCHA and browser repair paths |
+| `indeed-job-search` | Core | `ws`, workspace `better-sqlite3` | None | LinkedIn SQLite saver, CAPTCHA and browser repair paths |
+| `job-match-scorer` | Core | workspace `better-sqlite3` | Standard-library scorer | DOCX input contract, salary handoff |
+| `salary-calculator` | Core | `better-sqlite3` | None | Browser session bridge for posted-salary evidence |
+| `auto-job-application` | Core | `better-sqlite3`, `ws` | Standard-library helpers | CAPTCHA, Qwen recovery, Selenium visual recovery, DOCX/PDF |
+| `captcha-resolution` | Required support | `ws` | `Pillow` through its helper environment | Qwen recovery when visual interpretation is needed |
+| `qwen-screenshot-debug` | Required support | `ws` | Standard library | LM Studio/Qwen service |
+| `selenium-container-visual-click-recovery` | Required support | None | Standard library | Qwen screenshot debugging |
+| `obscura-mcp-repair` | Required support | None | None | Pi MCP repair |
+| `pi-mcp-repair` | Required support | None | None | Pi MCP gateway |
+| `brave-obscura-session` | Required support | `ws` | Standard library | Obscura MCP |
+| `docx` | Required support | None | PEP 723: `python-docx`, shared document helper | `uv` |
+| `pdf` | Required support | None | PEP 723: `pypdf`, `reportlab`, shared document helper | `uv` |
+
+The root `package.json` and `package-lock.json` pin the shared Node packages. DOCX and PDF helpers declare isolated Python dependencies in their PEP 723 scripts, run with `uv run`, and share the bundled `skills/_document_common/document_common.py` helper.
+
+## Executables
+
+| Executable | Requirement | Used by |
+|---|---|---|
+| Node.js 22 or newer and npm | Required | All JavaScript skills and bootstrap |
+| `sqlite3` | Required | Workspace diagnostics and database operations |
+| `python3` | Conditional | Scoring and ATS/document helpers |
+| `uv` | Conditional | DOCX and PDF helpers |
+| Docker with Compose | Conditional | Selenium Chromium and SearXNG services |
+| `ffmpeg` and `xdotool` | Conditional, inside recovery environment | Selenium visual click recovery |
+| Git | Required for source installation and updates | Bootstrap workflow |
+
+## Services
+
+| Service | Default endpoint | Requirement | Workflow |
+|---|---|---|---|
+| Selenium Chromium/CDP | `127.0.0.1:9225` | Conditional | LinkedIn/Indeed search and applications |
+| SearXNG | `127.0.0.1:8888` | Conditional | External job discovery |
+| LM Studio with Qwen VLM | `127.0.0.1:1234` | Optional | Screenshot interpretation and visual recovery |
+
+`compose.yaml` provides Selenium Chromium and SearXNG. The installer verifies heavyweight prerequisites but does not install Docker, start services, or download model weights.
+
+## MCP Integrations
+
+| Integration | Requirement | Used by |
+|---|---|---|
+| Obscura MCP | Conditional | Alternate authorized browser flows and repair |
+| Pi MCP gateway | Conditional | MCP repair skills |
+| Apple Mail MCP | Optional | Authorized email-code retrieval during applications |
+| Context-mode MCP | Optional | Large-output analysis by the controlling Pi agent |
+
+## Workspace Dependencies
+
+`$JOBHUNTER_HOME` defaults to `~/.job-hunter` and contains user-owned runtime state:
+
+- `jobhunter.sqlite`
+- `CV.docx`
+- `personal-info-cache.json`
+- `node_modules/better-sqlite3`
+- `node_modules/ws`
+- generated logs, backups, and run artifacts
+
+These files are installed or created locally and are excluded from the publication repository.
+
+## Workflow-Stage Optionality
+
+| Stage | Active skills | Required service or integration |
+|---|---|---|
+| Initialize and doctor | `job-hunter` | Local Node, npm, SQLite |
+| Search | `job-hunter`, LinkedIn, Indeed | Authenticated Selenium Chromium/CDP |
+| Discover | `job-hunter` | SearXNG |
+| Score | `job-match-scorer`, DOCX | Local CV and workspace profile |
+| Salary | `salary-calculator` | Database; browser access only for posted evidence |
+| Apply | `auto-job-application`, CAPTCHA support | Authenticated Selenium Chromium/CDP |
+| Visual recovery | Qwen and Selenium recovery skills | Optional LM Studio/Qwen, `ffmpeg`, `xdotool` |
+| MCP repair | Obscura and Pi MCP repair skills | Corresponding MCP server |
diff --git a/docs/prerequisites.md b/docs/prerequisites.md
new file mode 100644
index 0000000..0124f87
--- /dev/null
+++ b/docs/prerequisites.md
@@ -0,0 +1,45 @@
+# Prerequisites
+
+Host tools are required for the pipeline to operate. They must be installed manually — the installer and doctor verify their presence but never install host-level software silently.
+
+## Automatically Installed Code Dependencies
+
+The global bootstrap copies the bundled skills and runs `npm ci` from the shipped workspace lockfile. It does not install host tools, containers, MCP servers, authenticated browser sessions, or model weights.
+
+## Required Host Tools
+
+| Tool | Purpose | Verify | Install (macOS) |
+|------|---------|--------|------------------|
+| Node.js ≥ 22 | Script runtime | `node --version` | `brew install node` |
+| npm | Package manager | `npm --version` | bundled with Node |
+| uv ≥ 0.4 | PEP 723 Python helpers (docx, pdf) | `uv --version` | `brew install uv` |
+| python3 ≥ 3.11 | DOCX/PDF tools, inline extractions | `python3 --version` | bundled / `brew install python` |
+| sqlite3 CLI | Schema inspections, ad-hoc queries | `sqlite3 --version` | bundled / `brew install sqlite` |
+| docker + compose | Selenium Chromium, SearXNG | `docker compose version` | [Docker Desktop](https://docs.docker.com/desktop/) |
+| git | Installation, updates | `git --version` | `xcode-select --install` |
+
+## Container Services and Conditional Requirements
+
+| Tool | Stage | Verify | Notes |
+|------|-------|--------|-------|
+| Chromium CDP (`127.0.0.1:9225`) | Search, Apply | `bash scripts/rehearse-compose.sh` | Provided by `compose.yaml` |
+| SearXNG (`127.0.0.1:8888`) | Discover | `curl -sf http://127.0.0.1:8888/search?q=test` | Provided by `compose.yaml`; set `SEARXNG_SECRET` before startup |
+| Authenticated LinkedIn session | LinkedIn search | Manual login in Selenium browser | Session is user-managed |
+| Authenticated Indeed session | Indeed search | Manual login in Selenium browser | Session is user-managed |
+
+## Service Data and Secrets
+
+The installer creates `chromium-profile/` and `searxng/` under `$JOBHUNTER_HOME`; Compose bind-mounts them for browser state and SearXNG configuration. Set `SEARXNG_SECRET` to a random local value before `docker compose up -d`. The repository contains no default credential, and the installer never starts containers.
+
+## Optional
+
+| Tool | Stage | Purpose | Notes |
+|------|-------|---------|-------|
+| LM Studio (`127.0.0.1:1234`) | CAPTCHA solve, Visual debug | Qwen VLM inference | Never downloaded by installer |
+| Obscura MCP | Browse, Apply | Headless browsing alternative | Pi MCP integration; see `obscura-mcp-repair` skill |
+| Apple Mail MCP | Outreach | Cold email sending | Pi MCP integration |
+| Brave browser | Brave session bridge | Cookie relay to Obscura | `brave-obscura-session` skill |
+
+## Doctor
+
+`jh-doctor.mjs` checks all required and conditional prerequisites, reporting which are present, missing, or degraded. It distinguishes required failures (exit non-zero) from optional degradations (warning only).
diff --git a/docs/security-and-privacy.md b/docs/security-and-privacy.md
new file mode 100644
index 0000000..089e2e4
--- /dev/null
+++ b/docs/security-and-privacy.md
@@ -0,0 +1,54 @@
+# Security and Privacy
+
+## Local Data Locations
+
+All job-hunting data lives under `$JOBHUNTER_HOME` (default `~/.job-hunter`):
+
+| File | Content | Sensitive? |
+|------|---------|------------|
+| `jobhunter.sqlite` | Jobs, scores, salary, applications | Yes — contains work history, salary expectations |
+| `personal-info-cache.json` | Work auth, salary, notice, demographics | Yes — PII and employment details |
+| `CV.docx` | Resume | Yes |
+| `optional_documents/` | Cover letters, uploads | Yes |
+| `apply_logs/` | Per-application run logs | May contain form answers |
+| `backups/` | Timestamped DB snapshots | Yes — mirror of DB |
+
+None of these files are tracked in Git. `.gitignore` excludes them.
+
+## Sensitive-File Exclusions
+
+The release-safety gate (`scripts/check-release-safety.mjs`) scans all tracked files and rejects:
+
+- Filepaths matching personal-data patterns (`.sqlite`, `.docx` outside `examples/`, `.env`, `personal-info`)
+- File content containing real email addresses, phone numbers, API keys, or tokens
+- Absolute host-specific paths (`/Users/`, `/home/`) in bundled skill files
+
+## Credential Handling
+
+- Credentials live in `personal-info-cache.json` and authenticated browser cookies — never in this repository.
+- The installer copies only skill code, never workspace data.
+- Docker Compose binds `$JOBHUNTER_HOME` into the Selenium container for CV uploads; the Compose file itself contains no credentials.
+
+## Authenticated-Browser Boundaries
+
+- LinkedIn and Indeed sessions are user-managed. The project does not store, export, or replay credentials.
+- CAPTCHA, MFA, and access-control challenges are the user's responsibility. The `captcha-resolution` skill provides a local-vision assist for image-grid CAPTCHAs but does not bypass site security measures.
+- Brave-to-Obscura cookie relay (`brave-obscura-session`) operates on same-origin cookies within the user's own browser session. It never transmits cookies to third parties.
+
+## CAPTCHA and Access-Control Policy
+
+- `captcha-resolution` uses a local Qwen VLM (LM Studio) to identify image-grid tiles. It runs entirely on your machine.
+- The project does not use third-party CAPTCHA-solving services.
+- If a site blocks automation, the skill halts and surfaces the block. No retries that could violate terms of service.
+
+## Backup Expectations
+
+- `jh-backup.mjs` creates timestamped snapshots in `$JOBHUNTER_HOME/backups/`.
+- Backups are local-only. The project never uploads backups anywhere.
+- The installer does not create or modify backups.
+
+## Disclosure Implications
+
+- Applying to jobs is an externally visible action. The project applies only when the user instructs it to.
+- Outreach emails (via Apple Mail MCP or manual) are sent only with explicit user authorization.
+- No application data is shared with this project's maintainers or any third party.
diff --git a/examples/synthetic-workspace/README.md b/examples/synthetic-workspace/README.md
new file mode 100644
index 0000000..f0f22d7
--- /dev/null
+++ b/examples/synthetic-workspace/README.md
@@ -0,0 +1,11 @@
+# Synthetic Workspace Fixtures
+
+These files are synthetic test data for CI and local validation.
+They contain no real personal information.
+
+## Files
+
+- `sample-personal-info-cache.json` — minimal cache fixture with placeholder values.
+- `sample-cv.md` — plain-text CV stub for extraction tests.
+
+All files in `examples/` are scanned by the release-safety gate to confirm they contain no real data.
diff --git a/examples/synthetic-workspace/sample-cv.md b/examples/synthetic-workspace/sample-cv.md
new file mode 100644
index 0000000..350e09e
--- /dev/null
+++ b/examples/synthetic-workspace/sample-cv.md
@@ -0,0 +1,25 @@
+# Jane Doe
+
+London, United Kingdom · jane.doe@example.com · +44 7700 000000
+
+## Summary
+
+Synthetic CV for Job Hunter pipeline testing. No real data.
+
+## Experience
+
+### Senior Engineer — Acme Corp (2020–Present)
+- Led backend platform migration to event-driven architecture.
+- Reduced p99 latency by 40%.
+
+### Software Engineer — Beta Inc (2016–2020)
+- Built REST APIs serving 10k requests/second.
+- Introduced CI/CD pipeline reducing deploy time from hours to minutes.
+
+## Education
+
+BSc Computer Science — University of Example (2016)
+
+## Skills
+
+Python, TypeScript, PostgreSQL, Docker, Kubernetes, AWS, system design.
diff --git a/examples/synthetic-workspace/sample-personal-info-cache.json b/examples/synthetic-workspace/sample-personal-info-cache.json
new file mode 100644
index 0000000..5a4e7ff
--- /dev/null
+++ b/examples/synthetic-workspace/sample-personal-info-cache.json
@@ -0,0 +1,13 @@
+{
+ "fullName": "Jane Doe",
+ "email": "jane.doe@example.com",
+ "phone": "+44 7700 000000",
+ "location": "London, United Kingdom",
+ "workAuthorization": "Right to work in UK",
+ "noticePeriod": "4 weeks",
+ "currentSalary": 0,
+ "desiredSalary": 0,
+ "demographics": {
+ "gender": "Prefer not to say"
+ }
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..848e121
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,492 @@
+{
+ "name": "job-hunter",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "job-hunter",
+ "version": "0.0.1",
+ "dependencies": {
+ "better-sqlite3": "12.10.0",
+ "ws": "8.21.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/better-sqlite3": {
+ "version": "12.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
+ "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ },
+ "engines": {
+ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/node-abi": {
+ "version": "3.96.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
+ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..5ed642d
--- /dev/null
+++ b/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "job-hunter",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "description": "Pi Job Hunter — complete skill closure with idempotent installer",
+ "scripts": {
+ "test": "node --test test/*.test.mjs",
+ "test:skills": "node scripts/test-installed-skills.mjs",
+ "install:global": "node scripts/install.mjs",
+ "verify-runtime": "node scripts/verify-runtime-dependencies.mjs",
+ "verify:release": "node scripts/check-release-safety.mjs && node scripts/check-local-profile-leaks.mjs && node scripts/verify-skill-closure.mjs && node scripts/verify-dependency-matrix.mjs && node scripts/check-doc-commands.mjs && node scripts/check-required-doc-sections.mjs",
+ "rehearse:compose": "bash scripts/rehearse-compose.sh"
+ },
+ "dependencies": {
+ "better-sqlite3": "12.10.0",
+ "ws": "8.21.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+}
diff --git a/scripts/check-doc-commands.mjs b/scripts/check-doc-commands.mjs
new file mode 100644
index 0000000..cf0bc74
--- /dev/null
+++ b/scripts/check-doc-commands.mjs
@@ -0,0 +1,85 @@
+import { stat, readFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+/**
+ * Parse README.md for command references and verify each resolves to a
+ * shipped file or package script.
+ */
+export async function run(rootDir) {
+ const findings = [];
+ const output = [];
+ const readmePath = join(rootDir, 'README.md');
+
+ const s = await stat(readmePath).catch(() => null);
+ if (!s || !s.isFile()) {
+ findings.push({ type: 'missing-readme', path: 'README.md', detail: 'README.md not found' });
+ output.push('[FAIL] missing-readme: README.md — README.md not found');
+ return { exitCode: 1, output, findings };
+ }
+
+ const content = await readFile(readmePath, 'utf8');
+
+ // Extract file paths from code blocks and inline code
+ // Matches: node scripts/foo.mjs, ./scripts/foo.sh, scripts/foo.mjs
+ const fileRefRe = /(?:^|\s)(?:node |bash |\.\/)?(scripts\/[\w./-]+\.(?:mjs|js|sh))/gm;
+ const refs = new Set();
+ let m;
+ while ((m = fileRefRe.exec(content)) !== null) {
+ refs.add(m[1]);
+ }
+
+ // Also match `npm run
AI Architect`,
+ 'AI Architect',
+ 'AI Architect',
+);
+assert.equal(queryTitleBackfill.title, '', 'backfill must not use the search query as the job title evidence');
+assert.equal(classifyListing({ title: queryTitleBackfill.title, descriptionText: queryTitleBackfill.description_text, provisional: false }).label, 'Out of scope');
+
+const provisionalLeadership = rowFromResult({
+ url: 'https://jobs.lever.co/example/head-ai-engineering',
+ title: 'Head of AI Engineering',
+ content: 'Lead hiring, people management, and roadmap ownership for an AI engineering function.',
+}, 'AI Architect', 'Ireland');
+assert.ok(provisionalLeadership, 'AI-central provisional out-of-scope stubs must survive for JD backfill');
+assert.equal(provisionalLeadership.role_family_inferred, 'Out of scope');
+assert.equal(classifyListing({
+ title: 'Head of AI Engineering',
+ descriptionText: 'Own technical direction, architecture quality, engineering standards, and platform strategy for an AI platform. Manage 10 engineers across 2 teams.',
+ provisional: false,
+}).label, 'Leadership progression');
+
+const provisionalData = rowFromResult({
+ url: 'https://jobs.lever.co/example/data-ai-architect',
+ title: 'Data & AI Architect',
+ content: 'Own data modelling, governance, lakehouse, warehouse, Spark, Databricks, and Snowflake.',
+}, 'AI Architect', 'Ireland');
+assert.ok(provisionalData, 'Data & AI stubs must survive until the full JD distinguishes stretch from data-dominated scope');
+assert.equal(provisionalData.role_family_inferred, 'Out of scope');
+assert.equal(classifyListing({
+ title: 'Data & AI Architect',
+ descriptionText: 'Design AI system architecture; data governance and warehouse are secondary responsibilities.',
+ provisional: false,
+}).label, 'Data-domain stretch');
+
+const newRow = { source: 'external', job_id: 'new-job' };
+const existingRow = { source: 'external', job_id: 'existing-job' };
+const insertDb = {
+ prepare(sql) {
+ if (sql.includes('SELECT 1')) return { get: (_source, jobId) => jobId === 'existing-job' ? { 1: 1 } : undefined };
+ if (sql.includes('INSERT OR IGNORE')) return { run: () => ({ changes: 1 }) };
+ throw new Error(`Unexpected SQL in insert fixture: ${sql}`);
+ },
+ transaction(fn) { return fn; },
+};
+const insertResult = insertDiscoveredRows(insertDb, [existingRow, newRow], false);
+assert.equal(insertResult.inserted, 1);
+assert.deepEqual(insertResult.insertedRows, [newRow]);
+
+const fullLeadershipDescription = 'Own technical direction, architecture quality, engineering standards, and platform strategy for an AI platform. Manage 10 engineers across 2 teams. Hire and mentor engineers while leading cross-team production delivery and the technical roadmap.';
+const html = ``;
+const server = createServer((req, res) => {
+ if (req.url === '/fail') {
+ res.writeHead(503);
+ res.end('unavailable');
+ return;
+ }
+ res.writeHead(200, { 'content-type': 'text/html' });
+ res.end(html);
+});
+await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+try {
+ const { port } = server.address();
+ const updates = [];
+ const backfillDb = {
+ prepare(sql) {
+ if (sql.includes('language_filter_reason = NULL')) return { run: (params) => updates.push({ type: 'success', ...params }) };
+ if (sql.includes('language_filter_reason = @reason')) return { run: (params) => updates.push({ type: 'failure', ...params }) };
+ throw new Error(`Unexpected SQL in backfill fixture: ${sql}`);
+ },
+ };
+ const backfillResult = await backfillRows(backfillDb, { dryRun: false, json: true }, [{
+ source: 'external',
+ job_id: 'backfill-job',
+ url: `http://127.0.0.1:${port}/head-ai-engineering`,
+ title: 'Head of AI Engineering',
+ company: 'Example Co',
+ searched_keywords: 'AI Architect',
+ }]);
+ assert.equal(backfillResult[0].ok, true);
+ assert.equal(backfillResult[0].role_family_inferred, 'Leadership progression');
+ assert.equal(updates[0].type, 'success');
+ assert.equal(updates[0].role_family_inferred, 'Leadership progression');
+ assert.equal(updates[0].language_filter_reason, undefined);
+
+ const failed = await backfillRows(backfillDb, { dryRun: false, json: true }, [{
+ source: 'external',
+ job_id: 'failed-job',
+ url: `http://127.0.0.1:${port}/fail`,
+ title: 'AI Architect',
+ company: 'Example Co',
+ searched_keywords: 'AI Architect',
+ }]);
+ assert.equal(failed[0].ok, false);
+ assert.match(updates[1].reason, /^backfill-failed:/);
+ assert.equal(updates[1].role_family_inferred, undefined, 'transport failures must not become role labels');
+} finally {
+ await new Promise((resolve) => server.close(resolve));
+}
+
+console.log('jh-discover tests: PASS');
diff --git a/skills/job-hunter/scripts/test-jh-freshness.mjs b/skills/job-hunter/scripts/test-jh-freshness.mjs
new file mode 100644
index 0000000..1bd30ee
--- /dev/null
+++ b/skills/job-hunter/scripts/test-jh-freshness.mjs
@@ -0,0 +1,118 @@
+#!/usr/bin/env node
+/**
+ * Unit tests for the jh-freshness.mjs posting-age filter.
+ * Rows older than 30 days are excluded by default. Fixtures are synthetic
+ * absolute-date, relative-date, and missing-signal cases.
+ */
+import assert from 'node:assert/strict';
+import { resolvePostingAge, classifyFreshness, filterByFreshness } from './jh-freshness.mjs';
+
+const NOW = new Date('2026-07-21T12:00:00Z');
+
+// ── ISO posting_date: straightforward age math ────────────────────────
+{
+ // Synthetic stale posting — posted 2026-03-02, about 141 days old
+ const resolved = resolvePostingAge('2026-03-02T13:01:27.407Z', '2026-06-07 15:26:36', NOW);
+ assert.equal(resolved.hasRealSignal, true);
+ assert.equal(resolved.method, 'posting_date_iso');
+ assert.ok(resolved.ageDays > 140 && resolved.ageDays < 142, `expected ~141 days, got ${resolved.ageDays}`);
+ console.log('✓ ISO posting_date resolves a synthetic stale age (~141d)');
+}
+
+// ── Relative text anchored to created_at (scrape time) ────────────────
+{
+ // LinkedIn "Reposted 4 days ago" scraped 2026-07-20 -> posted ~2026-07-16
+ const resolved = resolvePostingAge('London, England, United Kingdom · Reposted 4 days ago · 47 people clicked apply', '2026-07-20 07:47:58', NOW);
+ assert.equal(resolved.hasRealSignal, true);
+ assert.equal(resolved.method, 'relative_day');
+ assert.ok(resolved.ageDays >= 4 && resolved.ageDays <= 6, `expected ~5 days, got ${resolved.ageDays}`);
+ console.log('✓ relative "Reposted N days ago" text anchors to created_at correctly');
+}
+
+{
+ const resolved = resolvePostingAge('European Economic Area · Reposted 38 minutes ago · Over 100 people clicked apply', '2026-07-20 08:09:57', NOW);
+ assert.equal(resolved.hasRealSignal, true);
+ assert.equal(resolved.method, 'relative_minute');
+ console.log('✓ relative minutes-ago text parses');
+}
+
+{
+ const resolved = resolvePostingAge('Posted yesterday', '2026-07-02 05:25:02', NOW);
+ assert.equal(resolved.hasRealSignal, true);
+ assert.equal(resolved.method, 'yesterday');
+ console.log('✓ "Posted yesterday" resolves via the yesterday branch');
+}
+
+// ── No real signal at all: created_at is a lower bound, never asserted as fact ──
+{
+ // Synthetic no-signal job — job_posting_date null, discovered 71 days ago
+ const resolved = resolvePostingAge(null, '2026-05-11 20:06:45', NOW);
+ assert.equal(resolved.hasRealSignal, false);
+ assert.equal(resolved.ageDays, null, 'no fabricated ageDays when there is no real signal');
+ assert.ok(resolved.createdAgeDays > 69 && resolved.createdAgeDays < 72);
+ console.log('✓ no-signal case returns ageDays=null (never fabricates a posting date), exposes createdAgeDays as a lower bound only');
+}
+
+// ── classifyFreshness: real signal beyond cutoff -> stale ─────────────
+{
+ const row = { job_posting_date: '2026-03-02T13:01:27.407Z', created_at: '2026-06-07 15:26:36' };
+ const c = classifyFreshness(row, 30, NOW);
+ assert.equal(c.verdict, 'stale');
+ console.log('✓ classifyFreshness: 141-day-old real posting date -> stale');
+}
+
+// ── classifyFreshness: real signal within cutoff -> fresh ─────────────
+{
+ const row = { job_posting_date: '2026-07-07T13:36:45.14Z', created_at: '2026-07-07 21:01:46' };
+ const c = classifyFreshness(row, 30, NOW);
+ assert.equal(c.verdict, 'fresh');
+ console.log('✓ classifyFreshness: 14-day-old real posting date -> fresh');
+}
+
+// ── classifyFreshness: no signal, but discovery date alone exceeds cutoff -> stale ──
+{
+ // Example Company "AI Agents Solutions Architect" — no posting_date, discovered 52 days ago
+ const row = { job_posting_date: null, created_at: '2026-05-30 23:04:50' };
+ const c = classifyFreshness(row, 30, NOW);
+ assert.equal(c.verdict, 'stale', 'discovery date alone is a lower bound; if it already exceeds cutoff, job cannot be fresher');
+ assert.equal(c.method, 'no_signal_but_discovery_exceeds_cutoff');
+ console.log('✓ classifyFreshness: no signal + discovery date alone > cutoff -> certainly stale (lower-bound logic)');
+}
+
+// ── classifyFreshness: no signal, discovery date within cutoff -> unverified (kept, flagged) ──
+{
+ // "AI Systems Architect (Dutch & English)" — no posting_date, discovered 6 days ago
+ const row = { job_posting_date: null, created_at: '2026-07-15 23:44:28' };
+ const c = classifyFreshness(row, 30, NOW);
+ assert.equal(c.verdict, 'unverified', 'no signal but not provably stale -> unverified, not silently fresh');
+ console.log('✓ classifyFreshness: no signal but discovery date within cutoff -> unverified (not asserted fresh)');
+}
+
+// ── filterByFreshness: buckets a mixed batch correctly, default cutoff 30d ──
+{
+ const jobs = [
+ { title: 'Synthetic stale role (real signal)', job_posting_date: '2026-03-02T13:01:27.407Z', created_at: '2026-06-07 15:26:36' },
+ { title: 'Synthetic fresh role', job_posting_date: '2026-07-07T13:36:45.14Z', created_at: '2026-07-07 21:01:46' },
+ { title: 'Example Company (no signal, certainly stale by discovery date)', job_posting_date: null, created_at: '2026-05-30 23:04:50' },
+ { title: 'AI Systems Architect (no signal, unverified)', job_posting_date: null, created_at: '2026-07-15 23:44:28' },
+ ];
+ const { fresh, stale, unverified } = filterByFreshness(jobs, { now: NOW });
+ assert.equal(fresh.length, 1);
+ assert.equal(fresh[0].title, 'Synthetic fresh role');
+ assert.equal(stale.length, 2, 'both the real-signal-stale and no-signal-certainly-stale jobs land in stale');
+ assert.equal(unverified.length, 1);
+ assert.equal(unverified[0].title, 'AI Systems Architect (no signal, unverified)');
+ console.log('✓ filterByFreshness buckets a mixed batch into fresh/stale/unverified correctly');
+}
+
+// ── Custom cutoff is respected (not hardcoded to 30) ──────────────────
+{
+ const row = { job_posting_date: '2026-06-23T21:19:08.538Z', created_at: '2026-07-21 05:36:17' }; // ~27-28 days old
+ const c30 = classifyFreshness(row, 30, NOW);
+ const c14 = classifyFreshness(row, 14, NOW);
+ assert.equal(c30.verdict, 'fresh', '27-28 days old passes a 30-day cutoff');
+ assert.equal(c14.verdict, 'stale', '27-28 days old fails a stricter 14-day cutoff');
+ console.log('✓ cutoffDays is a real parameter, not hardcoded — same job classifies differently at 30d vs 14d');
+}
+
+console.log('\n── All jh-freshness.mjs tests passed ──\n');
diff --git a/skills/job-hunter/scripts/test-jh-migrate.mjs b/skills/job-hunter/scripts/test-jh-migrate.mjs
new file mode 100644
index 0000000..6855dc0
--- /dev/null
+++ b/skills/job-hunter/scripts/test-jh-migrate.mjs
@@ -0,0 +1,48 @@
+#!/usr/bin/env node
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { migrate } from './jh-migrate.mjs';
+
+const home = process.env.JOBHUNTER_HOME || path.join(process.env.HOME, '.job-hunter');
+const req = createRequire(path.join(home, 'package.json'));
+const Database = req('better-sqlite3');
+const root = mkdtempSync(path.join(tmpdir(), 'jh-migrate-test-'));
+const dbPath = path.join(root, 'fixture.sqlite');
+
+try {
+ const db = new Database(dbPath);
+ db.exec(`
+ CREATE TABLE jobs (
+ source TEXT NOT NULL,
+ job_id TEXT NOT NULL,
+ application_status TEXT,
+ applied_at TEXT,
+ role_family_inferred TEXT,
+ PRIMARY KEY (source, job_id)
+ );
+ INSERT INTO jobs (source, job_id, application_status, role_family_inferred)
+ VALUES ('linkedin', 'legacy-1', 'saved', 'ai_architecture');
+ `);
+ db.close();
+
+ const first = migrate(dbPath);
+ const second = migrate(dbPath);
+ assert.equal(first.taxonomyColumnAdded, true);
+ assert.equal(second.taxonomyColumnAdded, false);
+
+ const check = new Database(dbPath, { readonly: true });
+ const columns = check.prepare('PRAGMA table_info(jobs)').all().map((column) => column.name);
+ assert.equal(columns.filter((name) => name === 'role_taxonomy_version').length, 1);
+ const legacy = check.prepare('SELECT role_family_inferred, role_taxonomy_version FROM jobs WHERE job_id = ?').get('legacy-1');
+ assert.deepEqual(legacy, { role_family_inferred: 'ai_architecture', role_taxonomy_version: null });
+ assert.ok(check.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='application_stage_events'").get());
+ assert.ok(check.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='jh_meta'").get());
+ check.close();
+
+ console.log('jh-migrate tests: PASS');
+} finally {
+ rmSync(root, { recursive: true, force: true });
+}
diff --git a/skills/job-hunter/scripts/test-jh-report-gate.mjs b/skills/job-hunter/scripts/test-jh-report-gate.mjs
new file mode 100644
index 0000000..a696b6d
--- /dev/null
+++ b/skills/job-hunter/scripts/test-jh-report-gate.mjs
@@ -0,0 +1,219 @@
+#!/usr/bin/env node
+/**
+ * Unit tests for jh-report-gate.mjs — remediation R6 final-report gate.
+ * Uses a temp SQLite DB seeded with jobs/match_results/job_salary_observations;
+ * does NOT touch ~/.job-hunter/jobhunter.sqlite.
+ */
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { resolve, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { stripPartialSuffix, partialSuffixPath } from './jh-report-gate.mjs';
+
+const SCRIPT_DIR = resolve(fileURLToPath(import.meta.url), '..');
+const GATE_SCRIPT = join(SCRIPT_DIR, 'jh-report-gate.mjs');
+const DAY_MS = 86_400_000;
+const daysAgo = (days) => new Date(Date.now() - days * DAY_MS).toISOString();
+const today = new Date().toISOString();
+
+function makeTempDb() {
+ const dbPath = join(tmpdir(), `test-report-gate-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.sqlite`);
+ const schema = `
+ CREATE TABLE jobs (
+ source TEXT, job_id TEXT, title TEXT, company TEXT,
+ job_posting_date TEXT, created_at TEXT DEFAULT (datetime('now')),
+ PRIMARY KEY (source, job_id)
+ );
+ CREATE TABLE match_results (search_id TEXT, source TEXT, job_id TEXT, cta TEXT, fit_score REAL);
+ CREATE TABLE job_salary_observations (
+ job_source TEXT, job_id TEXT, observation_id TEXT,
+ is_posted_salary INTEGER, benchmark_id TEXT, observed_at TEXT,
+ PRIMARY KEY (job_source, job_id, observation_id)
+ );
+ `;
+ execFileSync('sqlite3', [dbPath], { input: schema });
+ return dbPath;
+}
+
+function sql(dbPath, statement) {
+ execFileSync('sqlite3', [dbPath], { input: statement });
+}
+
+function cleanupDb(dbPath) {
+ try { unlinkSync(dbPath); } catch {}
+}
+
+function runGate(args) {
+ try {
+ const out = execFileSync('node', [GATE_SCRIPT, ...args, '--json'], { encoding: 'utf8' });
+ return { status: 0, result: JSON.parse(out) };
+ } catch (e) {
+ return { status: e.status, result: e.stdout ? JSON.parse(e.stdout) : null, stderr: e.stderr };
+ }
+}
+
+// ── Pure helpers: PARTIAL suffix round-trip ───────────────────────────
+{
+ assert.equal(partialSuffixPath('/tmp/report.md'), '/tmp/report.PARTIAL.md');
+ assert.equal(partialSuffixPath('/tmp/report.PARTIAL.md'), '/tmp/report.PARTIAL.md', 'idempotent on already-PARTIAL');
+ assert.equal(stripPartialSuffix('/tmp/report.PARTIAL.md'), '/tmp/report.md');
+ assert.equal(stripPartialSuffix('/tmp/report.md'), '/tmp/report.md', 'no-op on non-PARTIAL');
+ console.log('✓ PARTIAL suffix helpers round-trip correctly');
+}
+
+// ── Gate passes when every Apply row has provenance ───────────────────
+{
+ const db = makeTempDb();
+ sql(db, `
+ INSERT INTO jobs (source,job_id,title,company) VALUES ('linkedin','1','AI Architect','Acme');
+ INSERT INTO match_results VALUES ('search-1','linkedin','1','Apply',85);
+ INSERT INTO job_salary_observations VALUES ('linkedin','1','obs-1',1,NULL,'${today}');
+ `);
+ const { status, result } = runGate(['--search-id', 'search-1', '--db', db]);
+ assert.equal(status, 0, 'gate exits 0 when all Apply rows have provenance');
+ assert.equal(result.passed, true);
+ assert.equal(result.missingProvenance.length, 0);
+ cleanupDb(db);
+ console.log('✓ gate PASSes when every Apply row has salary provenance');
+}
+
+// ── Gate fails when an Apply row has no observation at all ────────────
+{
+ const db = makeTempDb();
+ sql(db, `
+ INSERT INTO jobs (source,job_id,title,company) VALUES ('linkedin','2','AI Architect','Acme');
+ INSERT INTO match_results VALUES ('search-2','linkedin','2','Apply',85);
+ `);
+ const { status, result } = runGate(['--search-id', 'search-2', '--db', db]);
+ assert.equal(status, 1, 'gate exits 1 when an Apply row has no salary observation');
+ assert.equal(result.passed, false);
+ assert.equal(result.missingProvenance.length, 1);
+ assert.equal(result.missingProvenance[0].job_id, '2');
+ cleanupDb(db);
+ console.log('✓ gate fails when an Apply row has no salary observation at all');
+}
+
+// ── Estimate with benchmark_id counts as provenance; estimate WITHOUT benchmark_id does not ──
+{
+ const db = makeTempDb();
+ sql(db, `
+ INSERT INTO jobs (source,job_id,title,company) VALUES ('linkedin','3','AI Architect','Acme'), ('linkedin','4','AI Architect','Beta');
+ INSERT INTO match_results VALUES ('search-3','linkedin','3','Apply',85), ('search-3','linkedin','4','Apply',80);
+ INSERT INTO job_salary_observations VALUES ('linkedin','3','obs-3',0,'bench-1','${today}');
+ INSERT INTO job_salary_observations VALUES ('linkedin','4','obs-4',0,NULL,'${today}');
+ `);
+ const { status, result } = runGate(['--search-id', 'search-3', '--db', db]);
+ assert.equal(status, 1);
+ assert.equal(result.missingProvenance.length, 1, 'only the benchmark_id-less estimate is missing provenance');
+ assert.equal(result.missingProvenance[0].job_id, '4');
+ cleanupDb(db);
+ console.log('✓ estimate requires a real benchmark_id to count as provenance');
+}
+
+// ── Skip/Maybe rows never gate the report (only Apply rows matter) ────
+{
+ const db = makeTempDb();
+ sql(db, `
+ INSERT INTO jobs (source,job_id,title,company) VALUES ('linkedin','5','AI Architect','Acme');
+ INSERT INTO match_results VALUES ('search-4','linkedin','5','Skip',30);
+ `);
+ const { status, result } = runGate(['--search-id', 'search-4', '--db', db]);
+ assert.equal(status, 0, 'no Apply rows means nothing to gate on');
+ assert.equal(result.totalApplyRows, 0);
+ cleanupDb(db);
+ console.log('✓ Skip/Maybe rows do not block the gate; only Apply rows are checked');
+}
+
+// ── Degraded --run source status fails the gate even with clean provenance ──
+{
+ const db = makeTempDb();
+ sql(db, `
+ INSERT INTO jobs (source,job_id,title,company) VALUES ('linkedin','6','AI Architect','Acme');
+ INSERT INTO match_results VALUES ('search-5','linkedin','6','Apply',85);
+ INSERT INTO job_salary_observations VALUES ('linkedin','6','obs-6',1,NULL,'${today}');
+ `);
+ const { status, result } = runGate(['--search-id', 'search-5', '--db', db, '--run', 'nonexistent-run-id']);
+ assert.equal(status, 1, 'gate fails when a named run has no checkpoint (degraded source)');
+ assert.equal(result.degradedSources.length, 1);
+ cleanupDb(db);
+ console.log('✓ a --run with no/non-ok checkpoint degrades the gate regardless of provenance');
+}
+
+// ── Report file is renamed to .PARTIAL.md on failure, with findings appended ──
+{
+ const db = makeTempDb();
+ sql(db, `
+ INSERT INTO jobs (source,job_id,title,company) VALUES ('linkedin','7','AI Architect','Acme');
+ INSERT INTO match_results VALUES ('search-6','linkedin','7','Apply',85);
+ `);
+ const reportDir = join(tmpdir(), `gate-report-test-${Date.now()}`);
+ mkdirSync(reportDir, { recursive: true });
+ const reportPath = join(reportDir, 'report.md');
+ writeFileSync(reportPath, '# Report\nbody\n');
+
+ runGate(['--search-id', 'search-6', '--db', db, '--report', reportPath]);
+ assert.equal(existsSync(reportPath), false, 'original report.md renamed away');
+ const partialPath = join(reportDir, 'report.PARTIAL.md');
+ assert.equal(existsSync(partialPath), true, 'report.PARTIAL.md created');
+ const content = readFileSync(partialPath, 'utf8');
+ assert.match(content, /Degraded sources \/ missing provenance/);
+ assert.match(content, /linkedin:7/);
+
+ cleanupDb(db);
+ rmSync(reportDir, { recursive: true, force: true });
+ console.log('✓ failing gate renames report.md -> report.PARTIAL.md with findings appended');
+}
+
+// ── Stale posting (>30d, real signal) fails the gate even with clean provenance ──
+{
+ const db = makeTempDb();
+ sql(db, `
+ INSERT INTO jobs (source,job_id,title,company,job_posting_date,created_at)
+ VALUES ('indeed','8','AI Architect','Acme','${daysAgo(60)}','${daysAgo(60)}');
+ INSERT INTO match_results VALUES ('search-7','indeed','8','Apply',85);
+ INSERT INTO job_salary_observations VALUES ('indeed','8','obs-8',1,NULL,'${today}');
+ `);
+ const { status, result } = runGate(['--search-id', 'search-7', '--db', db]);
+ assert.equal(status, 1, 'gate fails when an Apply row has a real posting date older than the default 30-day cutoff');
+ assert.equal(result.stalePostings.length, 1);
+ assert.equal(result.stalePostings[0].job_id, '8');
+ cleanupDb(db);
+ console.log('✓ gate fails on a >30-day-old real posting date even with clean salary provenance');
+}
+
+// ── Fresh posting (<30d) passes the freshness check ──
+{
+ const db = makeTempDb();
+ sql(db, `
+ INSERT INTO jobs (source,job_id,title,company,job_posting_date,created_at)
+ VALUES ('indeed','9','AI Architect','Acme','${daysAgo(5)}','${daysAgo(5)}');
+ INSERT INTO match_results VALUES ('search-8','indeed','9','Apply',85);
+ INSERT INTO job_salary_observations VALUES ('indeed','9','obs-9',1,NULL,'${today}');
+ `);
+ const { status } = runGate(['--search-id', 'search-8', '--db', db]);
+ assert.equal(status, 0, 'a recently posted Apply row with clean provenance passes');
+ cleanupDb(db);
+ console.log('✓ gate PASSes on a fresh (<30d) posting with clean provenance');
+}
+
+// ── Custom --max-age-days is honored ──
+{
+ const db = makeTempDb();
+ sql(db, `
+ INSERT INTO jobs (source,job_id,title,company,job_posting_date,created_at)
+ VALUES ('indeed','10','AI Architect','Acme','${daysAgo(26)}','${daysAgo(26)}');
+ INSERT INTO match_results VALUES ('search-9','indeed','10','Apply',85);
+ INSERT INTO job_salary_observations VALUES ('indeed','10','obs-10',1,NULL,'${today}');
+ `);
+ // ~26 days old: passes default 30-day cutoff, fails a stricter 14-day cutoff
+ const { status: status30 } = runGate(['--search-id', 'search-9', '--db', db]);
+ assert.equal(status30, 0, '~26 days old passes the default 30-day cutoff');
+ const { status: status14 } = runGate(['--search-id', 'search-9', '--db', db, '--max-age-days', '14']);
+ assert.equal(status14, 1, '~26 days old fails a stricter --max-age-days 14');
+ cleanupDb(db);
+ console.log('✓ --max-age-days is a real parameter, not hardcoded to 30');
+}
+
+console.log('\n── All jh-report-gate.mjs tests passed ──\n');
diff --git a/skills/job-hunter/scripts/test-jh-search.mjs b/skills/job-hunter/scripts/test-jh-search.mjs
new file mode 100644
index 0000000..bde0e1e
--- /dev/null
+++ b/skills/job-hunter/scripts/test-jh-search.mjs
@@ -0,0 +1,143 @@
+#!/usr/bin/env node
+/**
+ * Unit tests for jh-search.mjs — the sanctioned single entry point for
+ * LinkedIn/Indeed searches (remediation R2/R3/R4/R5).
+ * Pure module surface — no CDP/browser/network. Exercises argument parsing,
+ * per-country domain/location derivation, and outcome classification.
+ */
+import assert from 'node:assert/strict';
+import {
+ parseArgs,
+ parseRefreshJobIds,
+ buildLinkedInArgs,
+ runId,
+ classifySearchOutcome,
+ INDEED_DOMAIN_BY_COUNTRY,
+ LOCATION_BY_COUNTRY,
+ EXIT,
+} from './jh-search.mjs';
+
+// ── One source + one country enforced ──────────────────────────────
+{
+ const opts = parseArgs(['--source', 'indeed', '--country', 'nl']);
+ assert.equal(opts.source, 'indeed');
+ assert.equal(opts.country, 'NL');
+ assert.equal(opts.domain, INDEED_DOMAIN_BY_COUNTRY.get('NL'));
+ assert.equal(opts.location, LOCATION_BY_COUNTRY.get('NL'));
+ console.log('✓ parseArgs derives Indeed domain + location from country code');
+}
+
+{
+ assert.throws(() => parseArgs(['--source', 'indeed', '--country', 'ZZ']),
+ /no known Indeed domain/, 'unknown country without --domain throws');
+ const explicit = parseArgs(['--source', 'indeed', '--country', 'ZZ', '--domain', 'https://zz.indeed.com']);
+ assert.equal(explicit.domain, 'https://zz.indeed.com');
+ console.log('✓ unknown country requires explicit --domain');
+}
+
+// ── Query-batch cap: default 3, hard cap 5 ───────────────────────────
+{
+ assert.equal(parseArgs(['--source', 'indeed', '--country', 'GB']).maxQueries, 3, 'default max-queries is 3');
+ assert.equal(parseArgs(['--source', 'indeed', '--country', 'GB', '--max-queries', '5']).maxQueries, 5);
+ assert.equal(parseArgs(['--source', 'indeed', '--country', 'GB', '--max-queries', '9']).maxQueries, 5, 'hard cap at 5');
+ assert.equal(parseArgs(['--source', 'indeed', '--country', 'GB', '--max-queries', '0']).maxQueries, 3, '0 is falsy, falls back to default 3');
+ console.log('✓ --max-queries is bounded to [1,5], default 3');
+}
+
+// ── LinkedIn requires no --domain ─────────────────────────────────────
+{
+ const opts = parseArgs(['--source', 'linkedin', '--country', 'IE']);
+ assert.equal(opts.domain, null);
+ assert.equal(opts.location, 'Ireland');
+ console.log('✓ LinkedIn invocation derives location without requiring a domain');
+}
+
+// ── LinkedIn refresh IDs are bounded and forwarded ───────────────────
+{
+ const opts = parseArgs(['--source', 'linkedin', '--country', 'IE', '--refresh-job-ids', '9000001003,9000001004,9000001003']);
+ assert.deepEqual(opts.refreshJobIds, ['9000001003', '9000001004']);
+ assert.deepEqual(parseRefreshJobIds(' 9000001003,9000001004 '), ['9000001003', '9000001004']);
+ assert.throws(() => parseRefreshJobIds('9000001003,abc'), /invalid LinkedIn job ID/);
+ assert.throws(() => parseRefreshJobIds(Array.from({ length: 51 }, (_, index) => String(index + 1)).join(',')), /max 50/);
+ assert.throws(() => parseArgs(['--source', 'indeed', '--country', 'GB', '--refresh-job-ids', '9000001003']), /only for LinkedIn/);
+ const args = buildLinkedInArgs(opts, '/tmp/out.json', '/tmp/summary.json');
+ const refreshIndex = args.indexOf('--refresh-job-ids');
+ assert.ok(refreshIndex >= 0);
+ assert.equal(args[refreshIndex + 1], '9000001003,9000001004');
+ console.log('✓ LinkedIn refresh IDs are validated, capped, deduplicated, and forwarded');
+}
+
+// ── runId is stable across --resume, fresh otherwise ─────────────────
+{
+ const resumed = parseArgs(['--source', 'linkedin', '--country', 'GB', '--resume', 'linkedin-GB-fixed-id']);
+ assert.equal(runId(resumed), 'linkedin-GB-fixed-id', '--resume pins the run id');
+ const fresh1 = runId(parseArgs(['--source', 'linkedin', '--country', 'GB']));
+ assert.match(fresh1, /^linkedin-GB-\d{8}-\d{6}$/, 'fresh run id is source-country-timestamp');
+ console.log('✓ runId: --resume pins id, fresh run derives a timestamped id');
+}
+
+// ── classifySearchOutcome: LinkedIn blocking states map to 'blocked' ──
+{
+ const opts = { source: 'linkedin' };
+ const blockedSummary = {
+ terminalStatuses: {
+ searchQueries: [{ query: 'AI Architect', status: 'active_challenge' }],
+ detailPages: { status: 'healthy' },
+ },
+ };
+ const outcome = classifySearchOutcome(opts, { status: 2 }, blockedSummary);
+ assert.equal(outcome.verdict, 'blocked');
+ assert.match(outcome.reason, /active_challenge/);
+ console.log('✓ classifySearchOutcome: LinkedIn active_challenge -> blocked verdict');
+}
+
+{
+ const opts = { source: 'linkedin' };
+ const rateLimitedDetail = {
+ terminalStatuses: {
+ searchQueries: [{ query: 'AI Architect', status: 'healthy' }],
+ detailPages: { status: 'blocked' },
+ },
+ };
+ const outcome = classifySearchOutcome(opts, { status: 2 }, rateLimitedDetail);
+ assert.equal(outcome.verdict, 'blocked', 'detail-page block also counts as blocked');
+ console.log('✓ classifySearchOutcome: LinkedIn detail-page block -> blocked verdict');
+}
+
+{
+ const opts = { source: 'linkedin' };
+ const cancelledOutcome = classifySearchOutcome(opts, { status: 3 }, { terminalStatuses: { searchQueries: [], detailPages: {} } });
+ assert.equal(cancelledOutcome.verdict, 'cancelled');
+ console.log('✓ classifySearchOutcome: exit 3 -> cancelled verdict');
+}
+
+// ── classifySearchOutcome: Indeed verification/CAPTCHA text -> blocked ──
+{
+ const opts = { source: 'indeed', domain: 'https://nl.indeed.com' };
+ const spawnResult = { status: 1, stderr: '❌ Indeed verification/CAPTCHA page detected at https://nl.indeed.com/jobs; user intervention required.' };
+ const outcome = classifySearchOutcome(opts, spawnResult, null);
+ assert.equal(outcome.verdict, 'blocked');
+ assert.match(outcome.reason, /verification\/CAPTCHA/);
+ console.log('✓ classifySearchOutcome: Indeed CAPTCHA stderr -> blocked verdict, not retried');
+}
+
+// ── classifySearchOutcome: non-blocking failures stay 'fatal', never silently retried ──
+{
+ const opts = { source: 'indeed', domain: 'https://uk.indeed.com' };
+ const outcome = classifySearchOutcome(opts, { status: 1, stderr: 'TypeError: cannot read property x' }, null);
+ assert.equal(outcome.verdict, 'fatal');
+ console.log('✓ classifySearchOutcome: ordinary script error -> fatal (not blocked, not auto-retried)');
+}
+
+// ── Exit code table matches the remediation plan contract ────────────
+{
+ assert.equal(EXIT.OK, 0);
+ assert.equal(EXIT.USAGE, 1);
+ assert.equal(EXIT.FATAL, 2);
+ assert.equal(EXIT.BUDGET, 3);
+ assert.equal(EXIT.BLOCKED, 4);
+ assert.equal(EXIT.PREFLIGHT, 5);
+ console.log('✓ EXIT codes match documented contract (0 ok / 1 usage / 2 fatal / 3 budget / 4 blocked / 5 preflight)');
+}
+
+console.log('\n── All jh-search.mjs tests passed ──\n');
diff --git a/skills/job-hunter/scripts/test-jh-semantic-shadow.mjs b/skills/job-hunter/scripts/test-jh-semantic-shadow.mjs
new file mode 100644
index 0000000..386abd6
--- /dev/null
+++ b/skills/job-hunter/scripts/test-jh-semantic-shadow.mjs
@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import http from 'node:http';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import {
+ REQUIRED_CATEGORIES,
+ cosineSimilarity,
+ validateFixture,
+} from './jh-semantic-shadow.mjs';
+
+const root = mkdtempSync(path.join(tmpdir(), 'jh-semantic-shadow-test-'));
+const fixturePath = new URL('../fixtures/role-classification-gold-v1.json', import.meta.url).pathname;
+const scriptPath = new URL('./jh-semantic-shadow.mjs', import.meta.url).pathname;
+const outPath = path.join(root, 'shadow-report.json');
+const copiedDb = path.join(root, 'jobhunter.sqlite');
+const canonicalDb = path.join(process.env.JOBHUNTER_HOME || path.join(process.env.HOME, '.job-hunter'), 'jobhunter.sqlite');
+const sha256 = (file) => createHash('sha256').update(readFileSync(file)).digest('hex');
+
+function categoryVector(text) {
+ const value = text.toLowerCase();
+ let index;
+ if (/bim|revit|construction|structural building/.test(value)) index = 7;
+ else if (/quota|revenue target|sales pipeline|commercial deals/.test(value)) index = 6;
+ else if (/backend software engineer|build web services|application features/.test(value)) index = 5;
+ else if (/lakehouse|snowflake|databricks|data governance/.test(value)) index = 4;
+ else if (/head of|director of|multiple ai engineering teams|manage three/.test(value)) index = 2;
+ else if (/forward deployed|staff ai engineer/.test(value)) index = 1;
+ else if (/customer engineer|solutions engineer/.test(value)) index = 3;
+ else index = 0;
+ return Array.from({ length: 8 }, (_, candidate) => candidate === index ? 1 : 0);
+}
+
+function runCli(args) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [scriptPath, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
+ child.on('error', reject);
+ child.on('close', (code) => code === 0 ? resolve({ stdout, stderr }) : reject(new Error(`shadow CLI exited ${code}: ${stderr}`)));
+ });
+}
+
+const server = http.createServer((request, response) => {
+ let raw = '';
+ request.setEncoding('utf8');
+ request.on('data', (chunk) => { raw += chunk; });
+ request.on('end', () => {
+ try {
+ const payload = JSON.parse(raw);
+ const inputs = Array.isArray(payload.input) ? payload.input : [payload.input];
+ response.writeHead(200, { 'content-type': 'application/json' });
+ response.end(JSON.stringify({
+ model: payload.model,
+ data: inputs.map((text, index) => ({ index, embedding: categoryVector(String(text)) })),
+ }));
+ } catch (error) {
+ response.writeHead(400, { 'content-type': 'application/json' });
+ response.end(JSON.stringify({ error: error.message }));
+ }
+ });
+});
+
+try {
+ const fixture = validateFixture(JSON.parse(readFileSync(fixturePath, 'utf8')));
+ assert.equal(fixture.reviewed, true);
+ assert.deepEqual(new Set(fixture.cases.map((item) => item.expectedCategory)), REQUIRED_CATEGORIES);
+ assert.ok(fixture.cases.every((item) => item.reviewReason));
+ assert.equal(cosineSimilarity([1, 0], [1, 0]), 1);
+ assert.equal(cosineSimilarity([1, 0], [0, 1]), 0);
+
+ if (existsSync(canonicalDb)) copyFileSync(canonicalDb, copiedDb);
+ else writeFileSync(copiedDb, 'portable database sentinel');
+ const dbBefore = sha256(copiedDb);
+
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ const address = server.address();
+ const endpoint = `http://127.0.0.1:${address.port}/v1/embeddings`;
+ await runCli([
+ '--fixture', fixturePath,
+ '--endpoint', endpoint,
+ '--model', 'stub-role-embeddings',
+ '--threshold', '0.5',
+ '--margin', '0.1',
+ '--out', outPath,
+ ]);
+
+ const report = JSON.parse(readFileSync(outPath, 'utf8'));
+ assert.equal(report.schemaVersion, 1);
+ assert.equal(report.mode, 'shadow');
+ assert.equal(report.modelId, 'stub-role-embeddings');
+ assert.ok(report.taxonomyVersion);
+ assert.match(report.fixture.sha256, /^[a-f0-9]{64}$/);
+ assert.equal(report.embeddingDimensions, 8);
+ assert.deepEqual(report.confusionMatrix, { truePositive: 5, trueNegative: 3, falsePositive: 0, falseNegative: 0 });
+ assert.deepEqual(report.unknownTitleRecovery, { total: 3, recovered: 3 });
+ assert.equal(report.evaluations.length, 8);
+ assert.equal(sha256(copiedDb), dbBefore, 'semantic shadow run leaves copied DB byte-identical');
+ console.log('jh-semantic-shadow tests: PASS');
+} finally {
+ await new Promise((resolve) => server.close(() => resolve()));
+ rmSync(root, { recursive: true, force: true });
+}
diff --git a/skills/job-hunter/scripts/test-role-taxonomy.mjs b/skills/job-hunter/scripts/test-role-taxonomy.mjs
new file mode 100644
index 0000000..8799992
--- /dev/null
+++ b/skills/job-hunter/scripts/test-role-taxonomy.mjs
@@ -0,0 +1,400 @@
+#!/usr/bin/env node
+import assert from 'node:assert/strict';
+import {
+ ROLE_LABELS,
+ ROLE_TAXONOMY_VERSION,
+ assertRoleClassification,
+ classifyRole,
+ expandRoleQueries,
+} from './role-taxonomy.mjs';
+
+assert.equal(typeof ROLE_TAXONOMY_VERSION, 'string');
+assert.ok(ROLE_TAXONOMY_VERSION.length > 0, 'taxonomy version is non-empty');
+
+assert.deepEqual(ROLE_LABELS, [
+ 'Exact architecture',
+ 'Adjacent technical',
+ 'Leadership progression',
+ 'Leadership lateral',
+ 'Conditional',
+ 'Data-domain stretch',
+ 'Out of scope',
+]);
+
+const cases = [
+ {
+ name: 'core AI Architect',
+ input: { title: 'Senior AI Architect', descriptionText: 'Own the architecture and technical direction for production AI platforms.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'core Generative AI Architect',
+ input: { title: 'Generative AI / LLM Architect', descriptionText: 'Design enterprise generative AI and agentic system architecture.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'Applied AI Architect software building is not construction',
+ input: {
+ title: 'Applied AI Architect, Digital Natives',
+ descriptionText: 'Define AI architectural patterns, deploy OpenAI APIs, and build production prototypes, including building and presenting demos.',
+ },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'technical Forward Deployed Architect is eligible',
+ input: { title: 'Forward Deployed Architect', descriptionText: 'Design and deliver production LLM systems while owning customer system architecture and implementation decisions.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'sales-only Forward Deployed Architect is excluded',
+ input: { title: 'Forward Deployed Architect', descriptionText: 'Own sales pipeline, customer presentations, revenue targets, and quota without implementation responsibility.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'strong technical Solutions Engineer is eligible',
+ input: { title: 'Solutions Engineer — AI', descriptionText: 'Own production AI system architecture, implementation design, cloud integration, and technical decisions.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'adjacent Principal AI Engineer with architecture evidence',
+ input: { title: 'Principal AI Engineer', descriptionText: 'Lead system design, production architecture, and platform decisions for AI services.' },
+ label: 'Adjacent technical',
+ },
+ {
+ name: 'feature-only adjacent title rejected',
+ input: { title: 'Staff AI Engineer', descriptionText: 'Implement product features and train models; no architecture or platform ownership.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'architect-named adjacent role still needs technical evidence',
+ input: { title: 'AI Enablement Architect', descriptionText: 'Coordinate adoption workshops and stakeholder communications; no system design or platform ownership.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'small technical AI manager is lateral',
+ input: { title: 'Engineering Manager — AI/ML', descriptionText: 'Manage a team of 4 engineers and own technical direction, architecture quality, and engineering standards.' },
+ label: 'Leadership lateral',
+ },
+ {
+ name: 'eight engineers is progression',
+ input: { title: 'Engineering Manager — AI Platform', descriptionText: 'Lead 8 engineers across two teams. Own platform strategy, technical direction, hiring, and cross-team architecture.' },
+ label: 'Leadership progression',
+ },
+ {
+ name: 'written eight engineers is progression',
+ input: { title: 'Engineering Manager — AI Platform', descriptionText: 'Own technical direction for eight engineers across two teams.' },
+ label: 'Leadership progression',
+ },
+ {
+ name: 'cross-team collaboration alone stays lateral',
+ input: { title: 'Engineering Manager — AI/ML', descriptionText: 'Manage a team of 4 engineers, collaborate cross-team, and own technical direction.' },
+ label: 'Leadership lateral',
+ },
+ {
+ name: 'delivery manager without technical authority rejected',
+ input: { title: 'Technical Delivery Manager — AI', descriptionText: 'Own milestones, status reporting, ceremonies, and headcount planning for an AI delivery team.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'AI leadership without technical direction rejected',
+ input: { title: 'Head of AI Engineering', descriptionText: 'Own hiring, budgets, people development, and delivery roadmap; no architecture or technical decision authority.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'AI leadership needs technical authority evidence',
+ input: { title: 'Head of AI Engineering', descriptionText: 'Own hiring, budgets, people development, and delivery roadmap for an AI team.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'hybrid AI engineering leadership',
+ input: { title: 'Director of AI Engineering', descriptionText: 'Own architecture standards and technical direction for 12 engineers across multiple teams while remaining hands-on in system design.' },
+ label: 'Leadership progression',
+ check(result) {
+ assert.equal(result.leadershipEvaluation.hybridArchitectureLeadership, true);
+ assert.equal(result.leadershipEvaluation.teamScope.engineers, 12);
+ },
+ },
+ {
+ name: 'commercial role with authority is conditional',
+ input: { title: 'AI Pre-Sales Solutions Architect', descriptionText: 'Lead customer AI system architecture, implementation design, and technical discovery. No quota.' },
+ label: 'Conditional',
+ },
+ {
+ name: 'commercial role without authority is excluded',
+ input: { title: 'AI Pre-Sales Solutions Architect', descriptionText: 'Own pipeline, customer relationships, presentations, and revenue growth. No architecture or implementation responsibility.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'quota-led sales is excluded',
+ input: { title: 'AI Solutions Architect — Quota Carrying', descriptionText: 'Own revenue quota and sales targets.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'policy-only governance is excluded',
+ input: { title: 'AI Governance Lead', descriptionText: 'Write responsible AI policy, compliance frameworks, and ethics guidance.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'technical governance is conditional',
+ input: { title: 'AI Governance Lead', descriptionText: 'Own implementation of AI governance controls, platform architecture, and technical risk decisions.' },
+ label: 'Conditional',
+ },
+ {
+ name: 'data and AI stretch',
+ input: { title: 'Data & AI Architect', descriptionText: 'Design AI systems; data modelling and governance are secondary responsibilities.' },
+ label: 'Data-domain stretch',
+ check(result) {
+ assert.ok(result.dataGap.missingSkills.includes('lakehouse'));
+ assert.ok(result.dataGap.missingSkills.includes('Databricks'));
+ assert.ok(result.reason.gaps.includes('lakehouse'));
+ },
+ },
+ {
+ name: 'data and AI present skills are normalized',
+ input: { title: 'Data & AI Architect', descriptionText: 'Design AI architecture; data modelling and Databricks are secondary responsibilities.' },
+ label: 'Data-domain stretch',
+ check(result) {
+ assert.deepEqual(result.dataGap.presentSkills, ['data modelling', 'Databricks']);
+ assert.ok(!result.dataGap.missingSkills.includes('Databricks'));
+ },
+ },
+ {
+ name: 'ordinary AI architect with incidental warehouse integration stays exact',
+ input: { title: 'AI Architect', descriptionText: 'Design production AI systems that integrate with a data warehouse.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'data-dominated AI architect is not ordinary architecture',
+ input: { title: 'AI Architect', descriptionText: 'Primarily own the data lakehouse, warehouse, Spark, Databricks, Snowflake, data modelling, and data governance platform.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'generic data architect is excluded',
+ input: { title: 'Principal Data Architect', descriptionText: 'Own enterprise data architecture and warehouse strategy.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'software engineer title always excluded',
+ input: { title: 'Lead Software Engineer — AI Platform', descriptionText: 'Own AI platform architecture and technical direction.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'scientist titles excluded',
+ input: { title: 'Research Scientist — Generative AI', descriptionText: 'Research large language models.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'non-software architect excluded',
+ input: { title: 'AI Building Architect', descriptionText: 'Design construction and BIM projects.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'generic architect without AI central rejected',
+ input: { title: 'Enterprise Architect', descriptionText: 'Own ERP, infrastructure, and business systems architecture.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'incidental AI mention is not central architecture evidence',
+ input: { title: 'Enterprise Architect', descriptionText: 'Occasionally advise the AI team while owning ERP architecture.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'governance architect remains core architecture',
+ input: { title: 'AI Governance Architect', descriptionText: 'Design responsible AI governance architecture and implementation controls.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'core title family coverage',
+ input: { title: 'Chief AI Architect', descriptionText: 'Own enterprise AI architecture and technical direction.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'core AI ML title family coverage',
+ input: { title: 'Principal Architect, AI/ML', descriptionText: 'Define production machine-learning architecture.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'core enterprise title family coverage',
+ input: { title: 'Enterprise AI Architect', descriptionText: 'Own enterprise AI system architecture.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'core solutions title family coverage',
+ input: { title: 'AI Solutions Architect', descriptionText: 'Design and govern production AI solution architecture.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'core platform title family coverage',
+ input: { title: 'AI Platform Architect', descriptionText: 'Own architecture for a production AI platform.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'core infrastructure title family coverage',
+ input: { title: 'AI Infrastructure Architect', descriptionText: 'Design cloud infrastructure architecture for AI workloads.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'core integration title family coverage',
+ input: { title: 'AI Integration Architect', descriptionText: 'Own integration architecture for enterprise AI systems.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'core security architect title family coverage',
+ input: { title: 'AI Security Architect', descriptionText: 'Design secure AI system architecture and implementation controls.' },
+ label: 'Exact architecture',
+ },
+ {
+ name: 'adjacent lead family coverage',
+ input: { title: 'AI Engineering Lead', descriptionText: 'Lead technical direction, system design, and production AI delivery.' },
+ label: 'Adjacent technical',
+ },
+ {
+ name: 'lead with people scope becomes leadership progression',
+ input: { title: 'AI Engineering Lead', descriptionText: 'Manage 10 engineers across two teams and own technical direction and architecture quality.' },
+ label: 'Leadership progression',
+ },
+ {
+ name: 'adjacent applied AI family coverage',
+ input: { title: 'Lead Applied AI Engineer', descriptionText: 'Own production architecture and platform decisions for applied AI.' },
+ label: 'Adjacent technical',
+ },
+ {
+ name: 'adjacent platform engineer family coverage',
+ input: { title: 'Staff AI Platform Engineer', descriptionText: 'Lead platform architecture and production system design.' },
+ label: 'Adjacent technical',
+ },
+ {
+ name: 'adjacent ML platform family coverage',
+ input: { title: 'ML Platform Lead', descriptionText: 'Own ML platform decisions, system design, and production architecture.' },
+ label: 'Adjacent technical',
+ },
+ {
+ name: 'adjacent MLOps family coverage',
+ input: { title: 'MLOps Architect', descriptionText: 'Design production ML operations architecture.' },
+ label: 'Adjacent technical',
+ },
+ {
+ name: 'adjacent enablement family coverage',
+ input: { title: 'AI Enablement Architect', descriptionText: 'Define architecture and platform enablement for production AI.' },
+ label: 'Adjacent technical',
+ },
+ {
+ name: 'leadership senior manager family coverage',
+ input: { title: 'Senior Engineering Manager — AI/ML', descriptionText: 'Own technical direction and architecture quality for 10 engineers.' },
+ label: 'Leadership progression',
+ },
+ {
+ name: 'leadership head platform family coverage',
+ input: { title: 'Head of AI Platform', descriptionText: 'Own platform strategy and technical direction for a department.' },
+ label: 'Leadership progression',
+ },
+ {
+ name: 'leadership director family coverage',
+ input: { title: 'AI Engineering Director', descriptionText: 'Own engineering standards, architecture quality, and organizational AI platform strategy.' },
+ label: 'Leadership progression',
+ },
+ {
+ name: 'leadership solutions architecture manager family coverage',
+ input: { title: 'Solutions Architecture Manager — AI', descriptionText: 'Own AI architecture quality and technical direction for a small team.' },
+ label: 'Leadership lateral',
+ },
+ {
+ name: 'conditional security specialist family coverage',
+ input: { title: 'Principal AI Security Specialist', descriptionText: 'Own secure AI system architecture and implementation governance.' },
+ label: 'Conditional',
+ },
+ {
+ name: 'conditional responsible AI family coverage',
+ input: { title: 'Responsible AI Lead', descriptionText: 'Define technical controls, implementation governance, and architecture for responsible AI.' },
+ label: 'Conditional',
+ },
+ {
+ name: 'conditional strategy family coverage',
+ input: { title: 'AI Strategy Lead', descriptionText: 'Own AI platform architecture, technical roadmap, and implementation decisions.' },
+ label: 'Conditional',
+ },
+ {
+ name: 'strategy leadership remit remains conditional',
+ input: { title: 'AI Strategy Director', descriptionText: 'Own AI platform architecture and technical roadmap for the organization.' },
+ label: 'Conditional',
+ },
+ {
+ name: 'commercial solutions architecture manager uses conditional remit',
+ input: { title: 'AI Solutions Architecture Manager — Pre-Sales', descriptionText: 'Own customer AI architecture and implementation design, without quota ownership.' },
+ label: 'Conditional',
+ },
+ {
+ name: 'generic data warehouse architect excluded',
+ input: { title: 'Data Warehouse Architect', descriptionText: 'Own warehouse architecture and data pipelines.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'sales executive excluded',
+ input: { title: 'AI Sales Executive', descriptionText: 'Own revenue targets and customer acquisition.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'policy-only strategy excluded',
+ input: { title: 'AI Strategy Lead', descriptionText: 'Write policy and provide non-technical strategic advice.' },
+ label: 'Out of scope',
+ },
+ {
+ name: 'product manager without engineering ownership rejected',
+ input: { title: 'AI Product Manager', descriptionText: 'Own product roadmap, launches, and stakeholder communications.' },
+ label: 'Out of scope',
+ },
+];
+
+for (const testCase of cases) {
+ const result = assertRoleClassification(classifyRole(testCase.input));
+ assert.equal(result.label, testCase.label, testCase.name);
+ assert.ok(result.confidence >= 0 && result.confidence <= 1, `${testCase.name}: confidence bounds`);
+ assert.equal(result.reason.queryUsedAsEvidence, false, `${testCase.name}: query excluded from evidence`);
+ assert.equal(typeof JSON.stringify(result.reason), 'string', `${testCase.name}: reason serializes`);
+ testCase.check?.(result);
+}
+
+const provisional = assertRoleClassification(classifyRole({ title: 'AI Architect', provisional: true }));
+assert.equal(provisional.provisional, true, 'provisional flag is preserved');
+assert.ok(provisional.confidence <= 0.65, 'provisional confidence is capped');
+
+const withQuery = classifyRole({
+ title: 'Enterprise Architect',
+ descriptionText: 'Own ERP architecture.',
+ query: 'AI Architect',
+});
+const withoutQuery = classifyRole({
+ title: 'Enterprise Architect',
+ descriptionText: 'Own ERP architecture.',
+});
+assert.deepEqual(withQuery, withoutQuery, 'query text never changes classification');
+
+const expanded = expandRoleQueries({
+ targetRole: 'AI Architect',
+ similarRoles: ['AI Architect', 'Principal AI Engineer', 'ai architect'],
+ maxQueries: 8,
+});
+assert.equal(expanded.length, 8, 'query expansion respects maxQueries');
+assert.equal(new Set(expanded.map((query) => query.toLowerCase())).size, expanded.length, 'query expansion is deduplicated');
+assert.equal(expanded[0], 'AI Architect', 'target query is first');
+assert.ok(expanded.includes('Principal AI Engineer'), 'similar query is retained');
+const fullExpansion = expandRoleQueries({ targetRole: 'AI Architect', maxQueries: 32 });
+for (const query of [
+ 'Applied AI Architect',
+ 'Forward Deployed Architect',
+ 'Forward Deployed Engineer',
+ 'AI Customer Engineer',
+ 'AI Field Engineer',
+]) {
+ assert.ok(fullExpansion.includes(query), `query expansion includes ${query}`);
+}
+assert.deepEqual(expandRoleQueries({ targetRole: 'AI Architect', maxQueries: 0 }), [], 'zero query bound returns empty');
+assert.deepEqual(expandRoleQueries({}), [], 'missing query seed returns empty');
+assert.ok(!expandRoleQueries({ targetRole: 'AI Architect', similarRoles: ['Lead Software Engineer — AI', 'Principal Data Architect'], maxQueries: 32 }).some((query) => /software engineer|data architect/i.test(query)), 'forbidden query families are filtered');
+
+assert.deepEqual(classifyRole(withQuery), classifyRole(withQuery), 'classification and reason are deterministic');
+assert.throws(() => assertRoleClassification({ ...withQuery, confidence: 2 }), /confidence/);
+assert.throws(() => assertRoleClassification({ ...withQuery, label: 'unknown' }), /Unknown/);
+assert.throws(() => assertRoleClassification({ ...withQuery, reason: { ...withQuery.reason, evidence: 'not-an-array' } }), /evidence/);
+
+console.log(`role-taxonomy tests: PASS (${cases.length} table cases)`);
diff --git a/skills/job-hunter/scripts/workspace-dependencies.mjs b/skills/job-hunter/scripts/workspace-dependencies.mjs
new file mode 100644
index 0000000..d7d08cd
--- /dev/null
+++ b/skills/job-hunter/scripts/workspace-dependencies.mjs
@@ -0,0 +1,11 @@
+import { createRequire } from 'node:module';
+import { join } from 'node:path';
+
+/** Load shared runtime packages from the canonical Job Hunter workspace. */
+const jobHunterHome = process.env.JOBHUNTER_HOME || join(process.env.HOME || process.cwd(), '.job-hunter');
+const requireWorkspace = createRequire(join(jobHunterHome, 'package.json'));
+
+export const WebSocketModule = requireWorkspace('ws');
+export const WebSocket = WebSocketModule.WebSocket || WebSocketModule;
+export const WebSocketServer = WebSocketModule.WebSocketServer;
+export const Database = requireWorkspace('better-sqlite3');
diff --git a/skills/job-match-scorer/SKILL.md b/skills/job-match-scorer/SKILL.md
new file mode 100644
index 0000000..05d7589
--- /dev/null
+++ b/skills/job-match-scorer/SKILL.md
@@ -0,0 +1,105 @@
+---
+name: job-match-scorer
+description: Scores saved jobs against the installing user's CV and local profile, persists fit and blocker evidence, and produces deterministic Apply/Skip recommendations. Use after job search and before salary enrichment or application.
+allowed-tools: read bash
+---
+
+# Job Match Scorer
+
+Score only from the installing user's local evidence. The repository contains no maintainer CV, work authorization, languages, salary, notice, relocation, or disclosure defaults.
+
+## Inputs
+
+Resolve `JOBHUNTER_HOME`, defaulting to `~/.job-hunter`:
+
+- `CV.docx`
+- `personal-info-cache.json`
+- `jobhunter.sqlite`
+
+Read the cache before evaluating languages, location, work authorization, sponsorship, relocation, notice, or other eligibility. Unknown values remain unknown.
+
+## Workflow
+
+### 1. Select Candidates
+
+Score jobs saved by the current search that have a non-empty title and description. Do not rescore an existing result for the same search unless the caller requested it.
+
+External discoveries require a successfully backfilled description. Report rows with missing descriptions rather than assigning a confident score.
+
+### 2. Classify the Role
+
+Use the shared classifier in `../job-hunter/scripts/role-taxonomy.mjs`. Query text is discovery context, not evidence. Listing-only classifications are provisional; refresh from the full job description when possible.
+
+Keep the classifier label, confidence, reason, and taxonomy version with the result. Generic AI/cloud role taxonomy is product code; user eligibility and experience are runtime data.
+
+### 3. Extract Requirements
+
+Separate:
+
+- mandatory skills and experience;
+- preferred or nice-to-have criteria;
+- language requirements;
+- location and work-mode requirements;
+- work-authorization, citizenship, clearance, and sponsorship requirements;
+- compensation or travel constraints.
+
+Do not turn a preferred criterion into a blocker. Preserve the source text supporting every mandatory requirement.
+
+### 4. Match Against Runtime Evidence
+
+A criterion is matched only when supported by the CV or explicit cache data. Do not infer a technology, employer, certification, language, citizenship, or authorization from related experience.
+
+Work-authorization logic is country-specific:
+
+- read the user's value for the job's country;
+- explicit no-sponsorship or citizenship requirements are blockers only when they conflict with that value;
+- ambiguous eligibility wording remains unresolved;
+- never derive authorization from nationality or residence.
+
+Mandatory language requirements become blockers only when the user's cache does not support the language. Nice-to-have languages are gaps, not blockers.
+
+### 5. Calculate the Score
+
+Use the actual criterion counts produced by the scorer. Recompute every ratio and percentage from those counts before persisting or presenting it. Do not narrate a score separately from the formula.
+
+The default application handoff threshold is `fit_score >= 60` when no hard blocker exists. Role-family labels and tailoring gaps inform the recommendation but do not silently override the numeric threshold.
+
+### 6. Persist Evidence
+
+Store the result in `match_results` with:
+
+- `fit_score`
+- `cta` (`Apply` or `Skip`)
+- `stretch_label`
+- blocker list
+- mandatory criteria found and matched
+- missing or unclear must-haves
+- tailoring suggestions
+- scoring basis and role-classification evidence
+
+JSON columns must contain stringified JSON arrays or objects, not language-native arrays passed directly to SQLite.
+
+### 7. Hand Off
+
+After scoring:
+
+1. Enrich salary evidence with `salary-calculator`.
+2. Exclude stale jobs through the Job Hunter freshness gate.
+3. Present every visible row with fit/category, role, salary provenance, posting age, reason/gaps, location/source, and clickable URL.
+4. Hand application candidates to `auto-job-application` only after blockers and unknown required answers are resolved.
+
+## Commands
+
+The deterministic scorer is `scripts/score_jobs_inline.py`. It reads jobs from JSON and writes scored JSON; callers should use temporary files or pipes and then persist through argv-safe database code.
+
+Run its focused tests with:
+
+```bash
+python3 scripts/test-score_jobs_inline.py
+```
+
+## References
+
+- [Adjacent role threshold policy](references/adjacent-role-threshold-policy.md)
+- [Adjacent AI role evidence](references/adjacent-ai-roles-title-gate.md)
+- [Application handoff threshold](references/user-threshold-ge60-application-handoff.md)
diff --git a/skills/job-match-scorer/references/adjacent-ai-roles-title-gate.md b/skills/job-match-scorer/references/adjacent-ai-roles-title-gate.md
new file mode 100644
index 0000000..3177c89
--- /dev/null
+++ b/skills/job-match-scorer/references/adjacent-ai-roles-title-gate.md
@@ -0,0 +1,5 @@
+# Adjacent AI Role Evidence
+
+Classify adjacent AI roles from the title plus job-description evidence. Relevant evidence includes technical direction, production architecture, platform ownership, system design, and engineering leadership.
+
+Listing-only results remain provisional. Refresh the detail page before treating an adjacent role as settled. Do not use the search query as evidence and do not assume the installing user's skills; match requirements against the runtime CV/cache.
diff --git a/skills/job-match-scorer/references/adjacent-role-threshold-policy.md b/skills/job-match-scorer/references/adjacent-role-threshold-policy.md
new file mode 100644
index 0000000..77c89af
--- /dev/null
+++ b/skills/job-match-scorer/references/adjacent-role-threshold-policy.md
@@ -0,0 +1,7 @@
+# Adjacent Role Threshold Policy
+
+Adjacent role titles are evidence labels, not automatic vetoes. A job with `fit_score >= 60` remains an application candidate when no hard blocker exists.
+
+A full description may support adjacent technical or leadership alignment through scope, architecture ownership, platform responsibility, and seniority. Query text alone is never classification evidence.
+
+Record the adjacent-role reason and tailoring gaps so the user can distinguish a core fit from a stretch application.
diff --git a/skills/job-match-scorer/references/user-threshold-ge60-application-handoff.md b/skills/job-match-scorer/references/user-threshold-ge60-application-handoff.md
new file mode 100644
index 0000000..62dba3f
--- /dev/null
+++ b/skills/job-match-scorer/references/user-threshold-ge60-application-handoff.md
@@ -0,0 +1,12 @@
+# Application Handoff Threshold
+
+The default handoff policy is:
+
+```text
+fit_score >= 60 and no hard blocker -> Apply candidate
+otherwise -> Skip or resolve the blocker
+```
+
+The score must be computed from persisted matched/total criterion counts. Hard blockers include only verified conflicts such as mandatory unsupported language, incompatible country-specific work authorization, explicit no-sponsorship requirements, or a clearly out-of-scope role.
+
+Unknown eligibility is not silently converted into either approval or rejection; surface it for resolution.
diff --git a/skills/job-match-scorer/scripts/score_jobs_inline.py b/skills/job-match-scorer/scripts/score_jobs_inline.py
new file mode 100644
index 0000000..03000a4
--- /dev/null
+++ b/skills/job-match-scorer/scripts/score_jobs_inline.py
@@ -0,0 +1,597 @@
+#!/usr/bin/env python3
+"""Inline scoring template for unscored jobs.
+
+Use this when the unscored queue exceeds 15 jobs (a leaf subagent times out
+around 40+ jobs at the 600s leaf budget). The script reads the CV, the
+unscored jobs JSON, and the personal-info cache, then emits a JSON array of
+score objects in the exact match_results INSERT format.
+
+Inputs (paths can be overridden via env vars):
+ $SCORE_CV_PATH default: $PWD/CV.docx
+ $SCORE_JOBS_PATH default: /tmp/jobs-to-score.json
+ $SCORE_OUTPUT_PATH default: /tmp/scores.json
+ $SCORE_CACHE_PATH default: $PWD/personal-info-cache.json
+ $SCORE_REQUIRE_TITLE default: "0" — retained for CLI compatibility; when
+ "1", add an adjacent-title tailoring note but never
+ veto a score-driven Apply decision.
+ $SCORE_TARGET_ROLE default: first few preferred roles from rolePreferences,
+ or "" when the cache has no rolePreferences (also used in
+ the deterministic search ID)
+ $SCORE_SEARCH_ID optional explicit override. Without it, the script derives
+ one stable ID from the target role, country scope, CV bytes,
+ scoring version, and canonical job payload.
+
+Usage:
+ python3 score_jobs_inline.py
+ SCORE_TARGET_ROLE="AI Architect; AI Lead" python3 score_jobs_inline.py
+"""
+import hashlib
+import html
+import json
+import os
+import re
+import subprocess
+import sys
+import zipfile
+from pathlib import Path
+
+# --- CV skill inventory (from SKILL.md "For each mandatory requirement") ---
+CV_SKILLS = {
+ "aws", "azure", "cosmos db", "cosmosdb", "blob storage", "azure ai search",
+ "azure web service", "azure functions", "entra id", "microsoft entra",
+ "langgraph", "langchain", "rag", "retrieval-augmented generation",
+ "openai", "ai agents", "agentic ai", "agentic", "prompt engineering",
+ "anthropic", "claude", "claude code", "github copilot", "copilot",
+ "java", "spring boot", "python", "typescript", "javascript",
+ "docker", "kubernetes", "k8s", "kafka",
+ "microservices", "api-driven design", "api design", "ddd",
+ "domain-driven design", "event-driven", "event-driven architecture",
+ "jenkins", "gitlab ci", "github actions",
+ "sql", "nosql", "dynamodb", "elastic search", "elasticsearch",
+ "oauth2", "sso", "oidc", "gdpr",
+ "az-900", "az-305", "safer agilist", "safer", "safe agilist",
+ "ci/cd", "ci-cd", "continuous integration", "continuous deployment",
+ "cloud migration", "microservice", "rest", "restful", "openapi",
+ "azure cloud", "aws lambda",
+ "ai architect", "solution architect", "cloud architect", "enterprise architect",
+ "integration architect", "technical architect", "software architect",
+ "platform architect", "ai engineer", "llm",
+}
+
+NON_CV_SKILLS = {
+ "databricks", "pyspark", "spark", "mlflow", "terraform", "pulumi",
+ "salesforce", "servicenow", "mulesoft", "sap", "oracle database",
+ "snowflake", "airflow", "hadoop", "react", "angular", "node.js", "nodejs",
+ "go", "golang", "rust", "c++", "c#", ".net", "power bi", "tableau",
+ "grafana", "prometheus", "istio", "linkerd", "helm", "argocd",
+ "murex", "calypso", "bloomberg", "figma", "redux", "vue", "vue.js",
+ "flutter", "swift", "kotlin", "android", "ios", "react native",
+ "jquery", "sass", "scss", "less", "graphql", "trpc", "deno", "bun",
+ "postman", "swagger", "fastapi", "django", "flask", "express",
+ "laravel", "rails", "ruby", "php", "perl", "scala", "groovy",
+ "r language", "matlab", "sas", "spss", "looker", "metabase",
+ "datadog", "splunk", "newrelic", "appdynamics", "dynatrace",
+ "selenium", "cypress", "playwright", "jest", "mocha", "junit",
+ "testng", "cucumber", "jmeter", "loadrunner", "soapui",
+ "rest-assured", "pytest",
+}
+
+MANDATORY_MARKERS = re.compile(
+ r"\b(required|must have|essential|strong background in|strong experience with|"
+ r"significant experience with|deep experience with|deep knowledge of|"
+ r"deep understanding of|proven experience in|proven track record with|"
+ r"demonstrated experience with|hands-on experience with|hands-on expertise in|"
+ r"expertise in|proficient in|solid experience with|you will work with|"
+ r"you will use|you will be responsible for|experience with)\b",
+ re.IGNORECASE,
+)
+NICE_TO_HAVE_MARKERS = re.compile(
+ r"\b(nice to have|preferred|bonus|plus|optional|familiarity with|"
+ r"exposure to|good to have|advantageous)\b",
+ re.IGNORECASE,
+)
+BUILDING_ARCH_TITLE = re.compile(
+ r"\b(architekt|architecte|architetto|bim|riba|part ii|revit|autocad|"
+ r"construction architect|building architect|innenarchitekt|"
+ r"architecte d.int.rieur|architetto d.interni|interior architect|"
+ r"draftsman|zeichner|bauleiter|praktikant|construction manager)\b",
+ re.IGNORECASE,
+)
+# Backward-compatible title matcher used when the cache has no usable taxonomy.
+FALLBACK_TITLE_MATCH = re.compile(
+ r"\b(ai architect|solution architect|cloud architect|enterprise architect|"
+ r"integration architect|technical architect|software architect|"
+ r"platform architect|ai lead|head of ai|ai engineer|principal architect|"
+ r"lead architect|senior architect|chief architect|pre.?sales.*architect|"
+ r"data architect|gen.*ai architect|generative ai architect|"
+ r"agentic.*ai architect|ai knowledge architect|ai solution architect|"
+ r"ai strategy|head of architecture|director of architecture|ai principal)\b",
+ re.IGNORECASE,
+)
+LANG_PATTERNS = {
+ "German": re.compile(r"\b(german|deutsch|fluent in german|german required)\b", re.IGNORECASE),
+ "French": re.compile(r"\b(french|français|fluent in french|french required)\b", re.IGNORECASE),
+ "Spanish": re.compile(r"\b(spanish|español|fluent in spanish)\b", re.IGNORECASE),
+ "English": re.compile(r"\b(english|fluent in english)\b", re.IGNORECASE),
+ "Italian": re.compile(r"\b(italian|italiano|fluent in italian)\b", re.IGNORECASE),
+}
+USER_SPOKEN = {"English", "Italian"}
+CLASSIFIER_CLI = Path(__file__).resolve().parents[2] / "job-hunter" / "scripts" / "role-classifier-cli.mjs"
+
+
+def load_role_preferences() -> dict | None:
+ """Load the optional machine-readable role taxonomy from the canonical cache."""
+ home = Path(os.environ.get("JOBHUNTER_HOME") or (Path.home() / ".job-hunter")).expanduser()
+ try:
+ with (home / "personal-info-cache.json").open(encoding="utf-8") as handle:
+ cache = json.load(handle)
+ preferences = cache.get("rolePreferences")
+ return preferences if isinstance(preferences, dict) else None
+ except (OSError, TypeError, ValueError):
+ return None
+
+
+def _role_titles(preferences: dict | None) -> list[str]:
+ if not preferences:
+ return []
+ titles: list[str] = []
+ groups = [
+ preferences.get("preferredPrimaryRoles"),
+ (preferences.get("adjacentRoles") or {}).get("adjacentTechnicalLeadership"),
+ (preferences.get("adjacentRoles") or {}).get("leadershipProgression"),
+ ]
+ for group in groups:
+ if not isinstance(group, list):
+ continue
+ for title in group:
+ if isinstance(title, str) and title.strip() and title.strip().lower() not in {
+ existing.lower() for existing in titles
+ }:
+ titles.append(title.strip())
+ return titles
+
+
+def build_title_match(preferences: dict | None) -> re.Pattern[str]:
+ """Build a title matcher from cached role titles, allowing spacing or hyphens."""
+ patterns = []
+ for title in _role_titles(preferences):
+ words = re.findall(r"[a-z0-9]+", title.lower())
+ if words:
+ patterns.append(r"\b" + r"[\s\-\u2013\u2014/,]+".join(re.escape(word) for word in words) + r"\b")
+ return re.compile("(?:" + "|".join(patterns) + ")", re.IGNORECASE) if patterns else FALLBACK_TITLE_MATCH
+
+
+ROLE_PREFERENCES = load_role_preferences()
+TITLE_MATCH = build_title_match(ROLE_PREFERENCES) if ROLE_PREFERENCES else FALLBACK_TITLE_MATCH
+PREFERRED_PRIMARY_ROLES = (
+ ROLE_PREFERENCES.get("preferredPrimaryRoles", [])
+ if ROLE_PREFERENCES and isinstance(ROLE_PREFERENCES.get("preferredPrimaryRoles"), list)
+ else []
+)
+DEFAULT_TARGET_ROLE = "; ".join(
+ role for role in PREFERRED_PRIMARY_ROLES[:3] if isinstance(role, str) and role.strip()
+) if PREFERRED_PRIMARY_ROLES else ""
+
+
+def job_description(job: dict) -> str:
+ """Return JD evidence without falling back to query/search metadata."""
+ for field in ("description", "description_text"):
+ value = job.get(field)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return ""
+
+
+def classify_jobs(jobs: list[dict]) -> list[dict]:
+ """Classify a batch through the shared JavaScript taxonomy."""
+ if not jobs:
+ return []
+ payload = [
+ {
+ "title": job.get("title") or "",
+ "descriptionText": job_description(job),
+ "jobFunction": job.get("jobFunction") or job.get("job_function") or "",
+ "industries": job.get("industries") or "",
+ }
+ for job in jobs
+ ]
+ if not CLASSIFIER_CLI.exists():
+ raise RuntimeError(f"Shared role classifier CLI not found: {CLASSIFIER_CLI}")
+ completed = subprocess.run(
+ ["node", str(CLASSIFIER_CLI)],
+ input=json.dumps(payload, sort_keys=True, separators=(",", ":")),
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ if completed.returncode != 0:
+ detail = (completed.stderr or completed.stdout).strip()
+ raise RuntimeError(f"Role classifier bridge failed: {detail}")
+ try:
+ results = json.loads(completed.stdout)
+ except json.JSONDecodeError as exc:
+ raise RuntimeError("Role classifier bridge returned invalid JSON") from exc
+ if not isinstance(results, list) or len(results) != len(jobs):
+ raise RuntimeError("Role classifier bridge returned the wrong batch length")
+ return results
+
+
+def extract_cv_text(path: str) -> str:
+ with zipfile.ZipFile(path) as z:
+ data = z.read("word/document.xml").decode("utf-8", "ignore")
+ data = re.sub(r"]*/>", " ", data)
+ data = re.sub(r"", "\n", data)
+ data = re.sub(r"<[^>]+>", "", data)
+ return html.unescape(data)
+
+
+def extract_mandatory_skills(desc: str) -> list[str]:
+ if not desc:
+ return []
+ skills: list[str] = []
+ for sent in re.split(r"(?<=[.!?;])\s+|\n", desc):
+ if not sent.strip():
+ continue
+ has_mandatory = bool(MANDATORY_MARKERS.search(sent))
+ if not has_mandatory:
+ continue
+ sent_lower = sent.lower()
+ candidates: set[str] = set()
+ for skill in CV_SKILLS | NON_CV_SKILLS:
+ if re.search(r"\b" + re.escape(skill) + r"\b", sent_lower):
+ candidates.add(skill)
+ for m in re.finditer(
+ r"experience (?:with|in)\s+([a-zA-Z][\w\s./+#-]{1,40}?)(?=[,;.]|\sand\s|\sor\s|$)",
+ sent, re.IGNORECASE,
+ ):
+ tok = m.group(1).strip().lower()
+ if 2 < len(tok) < 50:
+ candidates.add(tok)
+ for m in re.finditer(
+ r"knowledge of\s+([a-zA-Z][\w\s./+#-]{1,40}?)(?=[,;.]|\sand\s|\sor\s|$)",
+ sent, re.IGNORECASE,
+ ):
+ tok = m.group(1).strip().lower()
+ if 2 < len(tok) < 50:
+ candidates.add(tok)
+ for c in candidates:
+ if c not in skills:
+ skills.append(c)
+ return skills
+
+
+def check_cv_has_skill(skill: str) -> bool:
+ s = skill.lower().strip()
+ if s in CV_SKILLS:
+ return True
+ for cv_skill in CV_SKILLS:
+ if s in cv_skill or cv_skill in s:
+ return True
+ return False
+
+
+def detect_language_blocker(desc: str) -> bool:
+ if not desc:
+ return False
+ for lang, pattern in LANG_PATTERNS.items():
+ if lang in USER_SPOKEN:
+ continue
+ if not pattern.search(desc):
+ continue
+ for sent in re.split(r"(?<=[.!?;])\s+|\n", desc):
+ if pattern.search(sent):
+ if MANDATORY_MARKERS.search(sent) and not NICE_TO_HAVE_MARKERS.search(sent):
+ return True
+ if re.search(r"\b(must|required|essential|fluent|proficient|mandatory).*" +
+ pattern.pattern, sent, re.IGNORECASE):
+ return True
+ return False
+
+
+def role_reason_text(classification: dict) -> str:
+ reason = classification.get("reason") or {}
+ parts = [reason.get("summary") or classification.get("label") or "Role classification unavailable"]
+ evidence = reason.get("evidence") or []
+ gaps = reason.get("gaps") or []
+ if evidence:
+ parts.append(f"Evidence: {', '.join(evidence)}.")
+ if gaps:
+ parts.append(f"Gaps: {', '.join(gaps)}.")
+ evaluation = classification.get("leadershipEvaluation") or {}
+ if evaluation.get("isLeadership"):
+ scope = evaluation.get("teamScope") or {}
+ technical = evaluation.get("technicalOwnership") or {}
+ influence = evaluation.get("organizationalInfluence") or {}
+ growth = evaluation.get("growthPath") or {}
+ parts.append(
+ "Leadership evaluation: "
+ f"team scope engineers={scope.get('engineers')}, teams={scope.get('teams')}, "
+ f"multiple_teams={scope.get('multipleTeams')}, organizational_remit={scope.get('organizationalRemit')}; "
+ f"technical_ownership={technical.get('present')}; "
+ f"organizational_influence={influence.get('present')}; "
+ f"growth_path={growth.get('present')}; "
+ f"hands_on_transition_risk={evaluation.get('handsOnTransitionRisk')}; "
+ f"hybrid_architecture_leadership={evaluation.get('hybridArchitectureLeadership')}."
+ )
+ data_gap = classification.get("dataGap") or {}
+ if data_gap.get("applies"):
+ missing = data_gap.get("missingSkills") or []
+ if missing:
+ parts.append(f"Data-domain gaps: {', '.join(missing)}.")
+ return " ".join(parts)
+
+
+def _append_unique(items: list[str], value: str) -> None:
+ if value and value not in items:
+ items.append(value)
+
+
+def scored_search_ids(job: dict) -> set[str]:
+ """Read optional exported score metadata without requiring a DB write."""
+ values = []
+ for key in ("scored_search_ids", "existing_search_ids", "match_result_search_ids"):
+ value = job.get(key)
+ values.extend(value if isinstance(value, list) else [value] if value else [])
+ value = job.get("scored_search_id")
+ if value:
+ values.append(value)
+ match_results = job.get("match_results")
+ if isinstance(match_results, list):
+ values.extend(
+ row.get("search_id") for row in match_results
+ if isinstance(row, dict) and row.get("search_id")
+ )
+ elif isinstance(match_results, dict):
+ values.extend(
+ row.get("search_id") for row in match_results.values()
+ if isinstance(row, dict) and row.get("search_id")
+ )
+ return {str(value) for value in values if value}
+
+
+def jobs_unscored_for_search(jobs: list[dict], search_id: str) -> list[dict]:
+ """Keep rows without a result for this search, not merely without history."""
+ return [job for job in jobs if search_id not in scored_search_ids(job)]
+
+
+def score_job(
+ job: dict,
+ require_title_match: bool = False,
+ search_id: str | None = None,
+ role_classification: dict | None = None,
+) -> dict:
+ title = (job.get("title") or "").strip()
+ desc = job_description(job)
+ classification = role_classification or classify_jobs([job])[0]
+ role_label = classification["label"]
+ role_reason = role_reason_text(classification)
+ # jobs.role_family_reason is an existing structured TEXT field populated by
+ # discovery with the shared classifier's reason object. Keep that shape so
+ # scorer output can flow through the existing jobs-field integration path.
+ role_reason_json = json.dumps(classification.get("reason") or {}, sort_keys=True)
+
+ title_match = bool(TITLE_MATCH.search(title))
+ domain_match = bool(re.search(
+ r"\b(banking|fintech|finance|financial services|insurance|"
+ r"investment|asset management|wealth|trading|"
+ r"cloud|azure|aws|microservices|saas|enterprise|"
+ r"architect|architecture)\b", desc, re.IGNORECASE,
+ )) or title_match
+
+ mandatory_skills = extract_mandatory_skills(desc)
+ mandatory_matched = [s for s in mandatory_skills if check_cv_has_skill(s)]
+ tech_total = len(mandatory_skills)
+ tech_matched = len(mandatory_matched)
+ tech_ratio = tech_matched / tech_total if tech_total else 1.0
+ tech_stack_pass = (tech_total == 0) or (tech_ratio >= 0.5)
+ if tech_total >= 3 and tech_ratio < 0.3:
+ tech_stack_pass = False
+
+ seniority_match = bool(re.search(
+ r"\b(architect|principal|lead|head|director|chief|senior|staff)\b",
+ title, re.IGNORECASE,
+ )) or title_match
+ language_pass = not detect_language_blocker(desc)
+
+ blockers: list[str] = []
+ if not language_pass:
+ blockers.append("Language blocker (German/French/Spanish required)")
+ if BUILDING_ARCH_TITLE.search(title):
+ blockers.append("Role mismatch (building/construction architect)")
+
+ must_have_matched = sum([
+ 1 if title_match else 0,
+ 1 if domain_match else 0,
+ 1 if tech_stack_pass else 0,
+ 1 if seniority_match else 0,
+ 1 if language_pass else 0,
+ ])
+ fit_score = (must_have_matched / 5) * 100.0
+
+ if role_label == "Out of scope":
+ blockers.append(f"Role family blocker (Out of scope): {role_reason}")
+ has_blocker = bool(blockers)
+ # The application threshold is score-driven. Adjacent titles are tailoring
+ # signals only; they do not veto a score of 60 or higher.
+ cta = "Apply" if (fit_score >= 60 and not has_blocker) else "Skip"
+
+ # Stretch label — DB CHECK values only (see SKILL.md)
+ if has_blocker:
+ stretch = "Blocked"
+ elif must_have_matched == 5 and tech_ratio >= 0.8:
+ stretch = "Core fit"
+ elif must_have_matched >= 4 and not has_blocker:
+ stretch = "Stretch"
+ elif not title_match:
+ stretch = "Major domain stretch"
+ elif not tech_stack_pass and tech_total > 0:
+ stretch = "Major cloud stretch"
+ else:
+ stretch = "Stretch"
+
+ tailoring: list[str] = []
+ if require_title_match and not title_match:
+ tailoring.append(f"Title '{title}' is adjacent to the target role tier — emphasize transferable architecture evidence")
+ if tech_total > 0 and tech_matched < tech_total:
+ missing = [s for s in mandatory_skills if s not in mandatory_matched]
+ tailoring.append(f"CV lacks {len(missing)} of {tech_total} mandatory skills: {', '.join(missing[:5])}")
+ tailoring_effort = (
+ "high" if (tech_total > 0 and tech_matched < tech_total * 0.5) else
+ "medium" if tech_matched < tech_total else "low"
+ )
+
+ matched_must: list[str] = []
+ if title_match: matched_must.append("Title alignment")
+ if domain_match: matched_must.append("Domain overlap (banking/cloud/enterprise)")
+ if tech_stack_pass: matched_must.append(f"Tech stack: {tech_matched}/{tech_total} mandatory skills matched")
+ if seniority_match: matched_must.append("Seniority match")
+ if language_pass: matched_must.append("Language alignment")
+ missing_must: list[str] = []
+ if not title_match: missing_must.append(f"Title '{title}' not strictly AI/Solution/Cloud Architect")
+ if not tech_stack_pass:
+ if tech_total > 0:
+ missing_must.append(f"Tech stack: missing {', '.join([s for s in mandatory_skills if s not in mandatory_matched][:5])}")
+ else:
+ missing_must.append("Tech stack: no explicit mandatory skills listed")
+ if not seniority_match: missing_must.append("Seniority: title suggests IC/mid rather than architect/lead")
+ if not language_pass: missing_must.append("Language blocker")
+
+ if role_label == "Data-domain stretch":
+ data_gap = classification.get("dataGap") or {}
+ data_missing = data_gap.get("missingSkills") or []
+ if data_missing:
+ _append_unique(missing_must, f"Data-domain stretch gaps: {', '.join(data_missing)}")
+ _append_unique(tailoring, f"Data-domain stretch: explicitly address missing specialist skills ({', '.join(data_missing)})")
+ if (classification.get("leadershipEvaluation") or {}).get("isLeadership"):
+ evaluation = classification["leadershipEvaluation"]
+ scope = evaluation.get("teamScope") or {}
+ technical = evaluation.get("technicalOwnership") or {}
+ influence = evaluation.get("organizationalInfluence") or {}
+ growth = evaluation.get("growthPath") or {}
+ leadership_fact = (
+ "Leadership evaluation: "
+ f"team scope engineers={scope.get('engineers')}, teams={scope.get('teams')}, "
+ f"technical ownership={technical.get('present')}, "
+ f"organizational influence={influence.get('present')}, "
+ f"growth path={growth.get('present')}, "
+ f"hands-on transition risk={evaluation.get('handsOnTransitionRisk')}"
+ )
+ _append_unique(missing_must, leadership_fact)
+ _append_unique(tailoring, leadership_fact)
+
+ has_blocker = bool(blockers)
+ if has_blocker:
+ cta = "Skip"
+
+ return {
+ "search_id": search_id,
+ "source": job.get("source"),
+ "job_id": job.get("job_id"),
+ "fit_score": round(fit_score, 1),
+ "cta": cta,
+ "stretch_label": stretch,
+ "role_family_inferred": role_label,
+ "role_family_confidence": classification.get("confidence"),
+ "role_family_reason": role_reason_json,
+ "must_have_total": 5,
+ "must_have_matched": must_have_matched,
+ "tech_stack_total": tech_total,
+ "tech_stack_matched": tech_matched,
+ "mandatory_skills_found_json": json.dumps(mandatory_skills),
+ "mandatory_skills_matched_json": json.dumps(mandatory_matched),
+ "nice_to_have_total": 0,
+ "nice_to_have_matched": 0,
+ "has_language_blocker": 0 if language_pass else 1,
+ "has_country_mismatch": 0,
+ "has_work_mode_mismatch": 0,
+ "tailoring_effort": tailoring_effort,
+ "matched_must_haves_json": json.dumps(matched_must),
+ "missing_or_unclear_must_haves_json": json.dumps(missing_must),
+ "matched_nice_to_haves_json": json.dumps([]),
+ "tailoring_suggestions_json": json.dumps(tailoring),
+ "blockers_json": json.dumps(blockers),
+ }
+
+
+def slug(value: str, fallback: str) -> str:
+ normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
+ return normalized[:32] or fallback
+
+
+def deterministic_search_id(jobs: list[dict], cv_path: str, require_title: bool) -> str:
+ explicit = os.environ.get("SCORE_SEARCH_ID")
+ if explicit:
+ return explicit
+
+ target_role = os.environ.get("SCORE_TARGET_ROLE") or DEFAULT_TARGET_ROLE
+ countries = sorted({
+ str(job.get("country_code") or job.get("countryCode") or "").upper()
+ for job in jobs
+ if job.get("country_code") or job.get("countryCode")
+ })
+ canonical_jobs = sorted(
+ ({
+ "source": job.get("source"),
+ "job_id": job.get("job_id"),
+ "title": job.get("title"),
+ "company": job.get("company"),
+ "country_code": job.get("country_code") or job.get("countryCode"),
+ "description": job.get("description") or job.get("description_text"),
+ } for job in jobs),
+ key=lambda job: (str(job["source"] or ""), str(job["job_id"] or "")),
+ )
+ payload = {
+ "scorer_version": "inline-v2",
+ "target_role": target_role,
+ "require_title": require_title,
+ "cv_sha256": hashlib.sha256(Path(cv_path).read_bytes()).hexdigest(),
+ "jobs": canonical_jobs,
+ }
+ digest = hashlib.sha256(
+ json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
+ ).hexdigest()[:12]
+ role_part = slug(target_role, "targeted")
+ country_part = "-".join(countries).lower() or "global"
+ return f"score-{role_part}-{country_part}-{digest}"
+
+
+def main():
+ cv_path = os.environ.get("SCORE_CV_PATH", str(Path.cwd() / "CV.docx"))
+ jobs_path = os.environ.get("SCORE_JOBS_PATH", "/tmp/jobs-to-score.json")
+ output_path = os.environ.get("SCORE_OUTPUT_PATH", "/tmp/scores.json")
+ require_title = os.environ.get("SCORE_REQUIRE_TITLE", "0") == "1"
+
+ if not Path(cv_path).exists():
+ sys.exit(f"CV not found: {cv_path}")
+ if not Path(jobs_path).exists():
+ sys.exit(f"Jobs JSON not found: {jobs_path}")
+ extract_cv_text(cv_path) # sanity check parse
+
+ jobs = json.loads(Path(jobs_path).read_text())
+ if isinstance(jobs, dict) and isinstance(jobs.get("jobs"), list):
+ jobs = jobs["jobs"]
+ if not isinstance(jobs, list):
+ sys.exit("Jobs JSON must contain an array of jobs")
+ search_id = deterministic_search_id(jobs, cv_path, require_title)
+ jobs_to_score = jobs_unscored_for_search(jobs, search_id)
+ classifications = classify_jobs(jobs_to_score)
+ print(f"Search ID: {search_id}", file=sys.stderr)
+ print(f"Scoring {len(jobs_to_score)} of {len(jobs)} jobs (require_title={require_title}) ...", file=sys.stderr)
+ scored = [
+ score_job(j, require_title_match=require_title, search_id=search_id, role_classification=classification)
+ for j, classification in zip(jobs_to_score, classifications)
+ ]
+ apply = sum(1 for s in scored if s["cta"] == "Apply")
+ skip = len(scored) - apply
+ print(f"Apply: {apply} Skip: {skip}", file=sys.stderr)
+ print("\nTop Apply jobs:", file=sys.stderr)
+ by_id = {(j.get("source"), j.get("job_id")): j for j in jobs}
+ for s in sorted([x for x in scored if x["cta"] == "Apply"], key=lambda x: -x["fit_score"])[:10]:
+ j = by_id.get((s["source"], s["job_id"]), {})
+ print(f" {s['fit_score']:.0f}% | {j.get('title','')} | {j.get('company','')} | {s['stretch_label']}", file=sys.stderr)
+ Path(output_path).write_text(json.dumps(scored, indent=2))
+ print(f"\nWrote {output_path} ({len(scored)} scores)", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/skills/job-match-scorer/scripts/test-score_jobs_inline.py b/skills/job-match-scorer/scripts/test-score_jobs_inline.py
new file mode 100644
index 0000000..16dbf1a
--- /dev/null
+++ b/skills/job-match-scorer/scripts/test-score_jobs_inline.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+import importlib.util
+import json
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+SCRIPT = Path(__file__).with_name("score_jobs_inline.py")
+CLI = SCRIPT.parents[2] / "job-hunter" / "scripts" / "role-classifier-cli.mjs"
+MODULE_HOME = tempfile.TemporaryDirectory()
+os.environ["JOBHUNTER_HOME"] = MODULE_HOME.name
+spec = importlib.util.spec_from_file_location("score_jobs_inline", SCRIPT)
+scorer = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(scorer)
+
+
+def title_match_in_home(home: Path, title: str) -> bool:
+ env = os.environ.copy()
+ env["JOBHUNTER_HOME"] = str(home)
+ probe = (
+ "import importlib.util, sys; "
+ "spec = importlib.util.spec_from_file_location('scorer', sys.argv[1]); "
+ "module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module); "
+ "raise SystemExit(0 if module.TITLE_MATCH.search(sys.argv[2]) else 1)"
+ )
+ completed = subprocess.run(
+ [sys.executable, "-c", probe, str(SCRIPT), title],
+ env=env,
+ check=False,
+ )
+ return completed.returncode == 0
+
+
+with tempfile.TemporaryDirectory() as temp_home:
+ home = Path(temp_home)
+ (home / "personal-info-cache.json").write_text(
+ json.dumps({"rolePreferences": {"preferredPrimaryRoles": ["Head of AI Platform"]}}),
+ encoding="utf-8",
+ )
+ assert title_match_in_home(home, "Head of AI Platform")
+
+with tempfile.TemporaryDirectory() as empty_home:
+ assert title_match_in_home(Path(empty_home), "AI Architect")
+
+
+def score(case):
+ classification = scorer.classify_jobs([case])[0]
+ return scorer.score_job(case, search_id="score-test", role_classification=classification)
+
+
+cases = [
+ {
+ "name": "cached adjacent title matches",
+ "job": {
+ "source": "test",
+ "job_id": "adjacent-60",
+ "title": "ML Platform Lead",
+ "description": "Lead technical direction for ML products.",
+ },
+ "label": "Adjacent technical",
+ "fit_score": 60.0,
+ "cta": "Apply",
+ },
+ {
+ "name": "out of scope is a role-family skip",
+ "job": {
+ "source": "test",
+ "job_id": "out-of-scope",
+ "title": "Data Scientist",
+ "description": "Research machine learning models and publish findings.",
+ },
+ "label": "Out of scope",
+ "cta": "Skip",
+ "blocker": "Role family blocker (Out of scope)",
+ },
+ {
+ "name": "data-domain stretch remains eligible and discloses gaps",
+ "job": {
+ "source": "test",
+ "job_id": "data-stretch",
+ "title": "Data & AI Architect",
+ "description": "Design AI architecture; data modelling and Databricks are secondary responsibilities.",
+ },
+ "label": "Data-domain stretch",
+ "cta": "Apply",
+ "missing": "Data-domain stretch gaps:",
+ "tailoring": "Data-domain stretch:",
+ },
+ {
+ "name": "leadership evaluation is carried into score fields",
+ "job": {
+ "source": "test",
+ "job_id": "leadership",
+ "title": "Engineering Manager — AI Platform",
+ "description": "Lead 8 engineers across two teams. Own platform strategy, technical direction, hiring, and cross-team architecture.",
+ },
+ "label": "Leadership progression",
+ "cta": "Apply",
+ "missing": "Leadership evaluation:",
+ "tailoring": "Leadership evaluation:",
+ "reason_field": "technicalOwnership",
+ },
+]
+
+for case in cases:
+ result = score(case["job"])
+ if case["name"] == "cached adjacent title matches":
+ gated_compat = scorer.score_job(
+ case["job"], require_title_match=True, role_classification=scorer.classify_jobs([case["job"]])[0]
+ )
+ assert gated_compat["fit_score"] == 60.0
+ assert gated_compat["cta"] == "Apply"
+ assert result["role_family_inferred"] == case["label"], case["name"]
+ if "fit_score" in case:
+ assert result["fit_score"] == case["fit_score"], case["name"]
+ assert result["cta"] == case["cta"], case["name"]
+ if "blocker" in case:
+ assert any(case["blocker"] in value for value in json.loads(result["blockers_json"])), case["name"]
+ if "missing" in case:
+ assert any(case["missing"] in value for value in json.loads(result["missing_or_unclear_must_haves_json"])), case["name"]
+ if "tailoring" in case:
+ assert any(case["tailoring"] in value for value in json.loads(result["tailoring_suggestions_json"])), case["name"]
+ if "reason_field" in case:
+ role_reason = json.loads(result["role_family_reason"])
+ assert case["reason_field"] in role_reason, case["name"]
+
+fallback = {
+ "source": "test",
+ "job_id": "description-fallback",
+ "title": "Enterprise Architect",
+ "description": "",
+ "description_text": "Own ERP architecture.",
+ "query": "AI Architect",
+}
+fallback_result = score(fallback)
+assert fallback_result["role_family_inferred"] == "Out of scope"
+assert "AI Architect" not in fallback_result["role_family_reason"]
+
+search_id = "score-current"
+filter_cases = [
+ {"job_id": "other", "scored_search_ids": ["score-other"]},
+ {"job_id": "current", "scored_search_ids": [search_id]},
+ {"job_id": "nested", "match_results": [{"search_id": search_id}]},
+ {"job_id": "new"},
+]
+assert [job["job_id"] for job in scorer.jobs_unscored_for_search(filter_cases, search_id)] == ["other", "new"]
+
+bridge_input = [case["job"] for case in cases]
+bridge_payload = [
+ {"title": job.get("title", ""), "descriptionText": scorer.job_description(job)}
+ for job in bridge_input
+]
+completed = subprocess.run(
+ ["node", str(CLI)],
+ input=json.dumps(bridge_payload),
+ text=True,
+ capture_output=True,
+ check=True,
+)
+cli_results = json.loads(completed.stdout)
+python_results = scorer.classify_jobs(bridge_input)
+assert [result["label"] for result in python_results] == [result["label"] for result in cli_results]
+assert [result["confidence"] for result in python_results] == [result["confidence"] for result in cli_results]
+assert [result["reason"] for result in python_results] == [result["reason"] for result in cli_results]
+
+alias_bridge = subprocess.run(
+ ["node", str(CLI)],
+ input=json.dumps([{
+ "title": "AI Architect",
+ "description": "Own production AI architecture.",
+ "query": "Data Architect",
+ }]),
+ text=True,
+ capture_output=True,
+ check=True,
+)
+alias_result = json.loads(alias_bridge.stdout)[0]
+assert alias_result["label"] == "Exact architecture"
+assert alias_result["reason"]["queryUsedAsEvidence"] is False
+
+MODULE_HOME.cleanup()
+print(f"score_jobs_inline tests: PASS ({len(cases)} table cases + fallback/filter/bridge parity)")
diff --git a/skills/linkedin-job-search/SKILL.md b/skills/linkedin-job-search/SKILL.md
new file mode 100644
index 0000000..52ad9d9
--- /dev/null
+++ b/skills/linkedin-job-search/SKILL.md
@@ -0,0 +1,85 @@
+---
+name: linkedin-job-search
+description: Searches LinkedIn jobs through the user's authorized Chromium CDP session, extracts job evidence, filters configured blockers, and saves normalized results to the canonical Job Hunter database. Use for LinkedIn-specific search and refresh work.
+allowed-tools: read bash
+---
+
+# LinkedIn Job Search
+
+Use the logged-in Chromium session on `127.0.0.1:9225`. Do not use raw HTTP, exported cookies, or a second browser for routine search.
+
+## Normal Entry Point
+
+Run through the Job Hunter wrapper:
+
+```bash
+node ~/.pi/agent/skills/job-hunter/scripts/jh-search.mjs \
+ --source linkedin \
+ --country \
+ --role ""
+```
+
+The wrapper performs the doctor check, exact-search-URL CDP preflight, bounded query expansion, checkpointing, and blocker handling described in `../job-hunter/references/search-safety-contract.md`.
+
+Direct calls to `scripts/search-linkedin-jobs.mjs` are for focused debugging and tests.
+
+## Workspace
+
+Resolve `JOBHUNTER_HOME`, defaulting to `~/.job-hunter`. Save to `jobhunter.sqlite`; never resolve job data from the launch directory.
+
+## Search Contract
+
+- Run one source and one country per invocation.
+- Keep query batches bounded and resumable.
+- Probe the exact search URL, not merely an already-open job-detail tab.
+- Send CDP keepalives during long browser work.
+- Stop on LinkedIn security checks, login walls, CAPTCHA, or verification pages.
+- Do not report a blocker as zero results.
+
+## Extraction
+
+LinkedIn search pages are client-rendered and may expose IDs through HTML or embedded data even when cards are incomplete. Extract unique job IDs from the authorized browser target, then fetch detail evidence through the same CDP session.
+
+For each job preserve:
+
+- source and job ID;
+- title, company, location, and posting date;
+- description text;
+- application URL and external ATS URL when visible;
+- applicant count and recruiter evidence when visible;
+- language and work-mode evidence;
+- extraction timestamp and search ID.
+
+Query text is not role-classification evidence. Listing-only role classifications remain provisional until detail extraction succeeds.
+
+## Filters
+
+Filter only from the installing user's runtime policy:
+
+- mandatory unsupported language;
+- explicit location/work-mode exclusions;
+- stale posting cutoff;
+- clearly irrelevant role family.
+
+Do not encode maintainer citizenship, authorization, sponsorship, relocation, salary, or language defaults in this skill. Pass eligibility evidence to `job-match-scorer`.
+
+## Persistence
+
+Use `scripts/save-to-sqlite.mjs` for normalized writes. It loads shared packages from the canonical workspace and preserves existing detail evidence on sparse updates.
+
+Run focused tests with the scripts named `test-*.mjs` under `scripts/` and the taxonomy tests under `tests/`.
+
+## Blockers and Recovery
+
+- LinkedIn security or verification page: stop and ask the user to resolve it in Chromium.
+- CDP unavailable: run `obscura-mcp-repair` only when the user chose an Obscura path; routine search remains direct Chromium CDP.
+- Unclear page state: use `qwen-screenshot-debug` before retrying navigation.
+- Application action: hand off to `auto-job-application`; the search skill does not answer screening questions.
+
+## References
+
+- [LinkedIn selectors](references/linkedin-selectors.md)
+- [Public listing fallback](references/public-listing-fallback.md)
+- [International screening evidence](references/us-remote-international-screening.md)
+- [Easy Apply interest loop](references/linkedin-easy-apply-interest-loop.md)
+- [Profile review privacy](references/linkedin-profile-review-cdp.md)
diff --git a/skills/linkedin-job-search/package.json b/skills/linkedin-job-search/package.json
new file mode 100644
index 0000000..53b90aa
--- /dev/null
+++ b/skills/linkedin-job-search/package.json
@@ -0,0 +1,5 @@
+{
+ "dependencies": {
+ "better-sqlite3": "^12.9.0"
+ }
+}
diff --git a/skills/linkedin-job-search/references/fixtures/job-card-1-expected.json b/skills/linkedin-job-search/references/fixtures/job-card-1-expected.json
new file mode 100644
index 0000000..87d919a
--- /dev/null
+++ b/skills/linkedin-job-search/references/fixtures/job-card-1-expected.json
@@ -0,0 +1,16 @@
+{
+ "title": "Senior Software Engineer",
+ "company": "Acme Corp",
+ "location": "San Francisco, CA",
+ "applicants": 25,
+ "description": "We are looking for a Senior Software Engineer with strong experience in distributed systems. Required: Fluent in English. Nice to have: German language skills.",
+ "applicationLinks": ["https://apply.acme.com/12345"],
+ "recruiter": "Jane Smith",
+ "recruiterEmail": "candidate@example.invalid",
+ "recruiterProfileLink": "https://www.linkedin.com/in/janesmith",
+ "jobPostingDate": null,
+ "languageRequirements": {
+ "required": ["English"],
+ "niceToHave": ["German"]
+ }
+}
\ No newline at end of file
diff --git a/skills/linkedin-job-search/references/fixtures/job-card-1.html b/skills/linkedin-job-search/references/fixtures/job-card-1.html
new file mode 100644
index 0000000..b5fb8f5
--- /dev/null
+++ b/skills/linkedin-job-search/references/fixtures/job-card-1.html
@@ -0,0 +1,15 @@
+
+
Senior Software Engineer
+
Acme Corp
+
San Francisco, CA
+
25 applicants
+
+
We are looking for a Senior Software Engineer with strong experience in distributed systems.
+
Required: Fluent in English. Nice to have: German language skills.
+
+
Apply externally
+
+
\ No newline at end of file
diff --git a/skills/linkedin-job-search/references/fixtures/job-detail-de-expected.json b/skills/linkedin-job-search/references/fixtures/job-detail-de-expected.json
new file mode 100644
index 0000000..5117ddb
--- /dev/null
+++ b/skills/linkedin-job-search/references/fixtures/job-detail-de-expected.json
@@ -0,0 +1,16 @@
+{
+ "title": "Produktmanager",
+ "company": "BerlinTech GmbH",
+ "location": "Berlin, Germany",
+ "applicants": 8,
+ "description": "Verantwortlich für die Produktentwicklung und Roadmap. Required: Fluent in German (C1). Must speak German. English is a plus.",
+ "applicationLinks": ["https://berlintech.apply.com/123"],
+ "recruiter": "Anna Mueller",
+ "recruiterEmail": "candidate@example.invalid",
+ "recruiterProfileLink": "https://www.linkedin.com/in/annamueller",
+ "jobPostingDate": "Posted on 2025-04-15",
+ "languageRequirements": {
+ "required": ["German"],
+ "niceToHave": ["English"]
+ }
+}
\ No newline at end of file
diff --git a/skills/linkedin-job-search/references/fixtures/job-detail-de.html b/skills/linkedin-job-search/references/fixtures/job-detail-de.html
new file mode 100644
index 0000000..cfcf984
--- /dev/null
+++ b/skills/linkedin-job-search/references/fixtures/job-detail-de.html
@@ -0,0 +1,18 @@
+
+
Produktmanager
+
BerlinTech GmbH
+
Berlin, Germany
+
8 applicants
+
Posted on 2025-04-15
+
+
Verantwortlich für die Produktentwicklung und Roadmap.
+
Required: Fluent in German (C1). Must speak German. English is a plus.
+
+
+
+
\ No newline at end of file
diff --git a/skills/linkedin-job-search/references/fixtures/job-detail-en-expected.json b/skills/linkedin-job-search/references/fixtures/job-detail-en-expected.json
new file mode 100644
index 0000000..ff0f5f1
--- /dev/null
+++ b/skills/linkedin-job-search/references/fixtures/job-detail-en-expected.json
@@ -0,0 +1,16 @@
+{
+ "title": "Data Analyst",
+ "company": "DataWorks Inc",
+ "location": "New York, NY",
+ "applicants": 12,
+ "description": "Analyze data to drive business decisions. Requirements: Fluent English. No other language required.",
+ "applicationLinks": [],
+ "recruiter": "Bob Johnson",
+ "recruiterEmail": "candidate@example.invalid",
+ "recruiterProfileLink": "https://www.linkedin.com/in/bobjohnson",
+ "jobPostingDate": "Posted 2 weeks ago",
+ "languageRequirements": {
+ "required": ["English"],
+ "niceToHave": []
+ }
+}
\ No newline at end of file
diff --git a/skills/linkedin-job-search/references/fixtures/job-detail-en.html b/skills/linkedin-job-search/references/fixtures/job-detail-en.html
new file mode 100644
index 0000000..fe1b12a
--- /dev/null
+++ b/skills/linkedin-job-search/references/fixtures/job-detail-en.html
@@ -0,0 +1,18 @@
+
+
Data Analyst
+
DataWorks Inc
+
New York, NY
+
12 applicants
+
Posted 2 weeks ago
+
+
Analyze data to drive business decisions.
+
Requirements: Fluent English. No other language required.
+
+
+
+
\ No newline at end of file
diff --git a/skills/linkedin-job-search/references/fixtures/job-detail-fr-nice-to-have-expected.json b/skills/linkedin-job-search/references/fixtures/job-detail-fr-nice-to-have-expected.json
new file mode 100644
index 0000000..b34d8b6
--- /dev/null
+++ b/skills/linkedin-job-search/references/fixtures/job-detail-fr-nice-to-have-expected.json
@@ -0,0 +1,16 @@
+{
+ "title": "Marketing Coordinator",
+ "company": "GlobalBrands",
+ "location": "Paris, France",
+ "applicants": 3,
+ "description": "Coordinate marketing campaigns across European markets. Required: Fluent English. French is a nice to have but not required.",
+ "applicationLinks": [],
+ "recruiter": "Claire Dubois",
+ "recruiterEmail": "candidate@example.invalid",
+ "recruiterProfileLink": "https://www.linkedin.com/in/clairedubois",
+ "jobPostingDate": "Posted 1 week ago",
+ "languageRequirements": {
+ "required": ["English"],
+ "niceToHave": ["French"]
+ }
+}
\ No newline at end of file
diff --git a/skills/linkedin-job-search/references/fixtures/job-detail-fr-nice-to-have.html b/skills/linkedin-job-search/references/fixtures/job-detail-fr-nice-to-have.html
new file mode 100644
index 0000000..0006ac1
--- /dev/null
+++ b/skills/linkedin-job-search/references/fixtures/job-detail-fr-nice-to-have.html
@@ -0,0 +1,18 @@
+
+
Marketing Coordinator
+
GlobalBrands
+
Paris, France
+
3 applicants
+
Posted 1 week ago
+
+
Coordinate marketing campaigns across European markets.
+
Required: Fluent English. French is a nice to have but not required.
+
+
+
+
\ No newline at end of file
diff --git a/skills/linkedin-job-search/references/fixtures/job-detail-no-applicants-expected.json b/skills/linkedin-job-search/references/fixtures/job-detail-no-applicants-expected.json
new file mode 100644
index 0000000..b658fd7
--- /dev/null
+++ b/skills/linkedin-job-search/references/fixtures/job-detail-no-applicants-expected.json
@@ -0,0 +1,16 @@
+{
+ "title": "Junior Developer",
+ "company": "StartupCo",
+ "location": "Remote",
+ "applicants": null,
+ "description": "Build and maintain web applications. Requirements: English proficiency. No foreign language requirement.",
+ "applicationLinks": ["https://startupco.apply.io/apply"],
+ "recruiter": null,
+ "recruiterEmail": null,
+ "recruiterProfileLink": null,
+ "jobPostingDate": "Posted 3 days ago",
+ "languageRequirements": {
+ "required": ["English"],
+ "niceToHave": []
+ }
+}
\ No newline at end of file
diff --git a/skills/linkedin-job-search/references/fixtures/job-detail-no-applicants.html b/skills/linkedin-job-search/references/fixtures/job-detail-no-applicants.html
new file mode 100644
index 0000000..b209415
--- /dev/null
+++ b/skills/linkedin-job-search/references/fixtures/job-detail-no-applicants.html
@@ -0,0 +1,15 @@
+
+
Junior Developer
+
StartupCo
+
Remote
+
+
Posted 3 days ago
+
+
Build and maintain web applications.
+
Requirements: English proficiency. No foreign language requirement.
+
+
+
+
\ No newline at end of file
diff --git a/skills/linkedin-job-search/references/linkedin-easy-apply-interest-loop.md b/skills/linkedin-job-search/references/linkedin-easy-apply-interest-loop.md
new file mode 100644
index 0000000..fa249b2
--- /dev/null
+++ b/skills/linkedin-job-search/references/linkedin-easy-apply-interest-loop.md
@@ -0,0 +1,5 @@
+# LinkedIn Easy Apply Interest Loop
+
+Easy Apply may return to an interest or review step after an attempted action. Detect the current dialog stage from visible headings and controls rather than assuming navigation succeeded.
+
+Do not answer screening questions in the search skill. Hand the job to `auto-job-application`, which loads the user's cache and asks for unknown required answers. A repeated dialog with no visible state change is a blocker, not a successful application.
diff --git a/skills/linkedin-job-search/references/linkedin-profile-review-cdp.md b/skills/linkedin-job-search/references/linkedin-profile-review-cdp.md
new file mode 100644
index 0000000..389d093
--- /dev/null
+++ b/skills/linkedin-job-search/references/linkedin-profile-review-cdp.md
@@ -0,0 +1,5 @@
+# LinkedIn Profile Review over CDP
+
+Use the authorized Chromium CDP session when saved-job, login, or regional profile context matters. Inspect only the profile fields needed for the user's request and do not copy profile content into fixtures, skill documentation, or logs.
+
+Profile review is read-only. Application answers still come from the canonical cache and CV; LinkedIn profile text is not a substitute for work authorization, salary, disclosure, or consent answers.
diff --git a/skills/linkedin-job-search/references/linkedin-selectors.md b/skills/linkedin-job-search/references/linkedin-selectors.md
new file mode 100644
index 0000000..f23ae16
--- /dev/null
+++ b/skills/linkedin-job-search/references/linkedin-selectors.md
@@ -0,0 +1,146 @@
+# LinkedIn Job Search — Selectors & Patterns
+
+> These selectors and patterns were validated against fixture HTML via the TDD test harness (`scripts/test-extraction-and-filter.mjs`). LinkedIn changes their DOM periodically — if extraction fails, update the patterns in `scripts/linkedin-extractor.mjs` and re-run tests.
+
+## URL Patterns
+
+| Purpose | URL |
+|---------|-----|
+| Job search (keyword + location) | `https://www.linkedin.com/jobs/search/?keywords={keywords}&location={location}` |
+| Job detail | `https://www.linkedin.com/jobs/view/{jobId}` |
+| Login (if session bridge fails) | `https://www.linkedin.com/uas/login` |
+
+## CSS Selectors (for Obscura `wait` / `click` / `type`)
+
+### Job Detail Page (current working selectors)
+
+| Element | Selector | Notes |
+|---------|----------|-------|
+| Title | `h1.top-card-layout__title` | Large heading at top of detail page |
+| Company | `a.topcard__org-name-link` | Link next to company logo |
+| Location | `span.topcard__flavor--bullet` | Bullet-separated location text |
+| Applicants | `figure.num-applicants__figure` | Contains applicant count text |
+| Description | `div.description__text` | Main content block with paragraphs |
+| Posting date | `span.job-posted-date` | Text like "Posted 2 weeks ago" |
+| Apply button | `.apply-button`, `.apply-link`, `.easy-apply` | |
+| Recruiter/hirer | `.hirer-info` > `.hirer-name` | May be absent |
+| Recruiter email | `.hirer-email` | Email address in hiring panel |
+| Recruiter profile link | `a.hirer-name` | `href` attribute on hirer name link |
+
+### Job Search Results (still valid)
+
+| Element | Selector | Notes |
+|---------|----------|-------|
+| Search keywords input | `#search-jobs-keywords` or `input[aria-label*="keyword"]` | |
+| Search location input | `#search-jobs-location` or `input[aria-label*="location"]` | |
+| Search submit button | `button[aria-label*="Search"]` or `.jobs-search-box__submit-button` | |
+| Job card in results | `.job-card-container` | Each card is a container |
+| Job title link | `.job-card-list__title` or `a.job-title` | Contains the job detail URL |
+| Company name | `.job-card-list__company-name` | |
+| Location | `.job-card-list__location` | |
+| Applicant count | `.job-card-list__applicant-count` | May be empty |
+
+### Deprecated Selectors (kept as fallbacks)
+
+| Element | Old Selector | New Replacement |
+|---------|-------------|----------------|
+| Title | `.job-details-jobs-v2__main-job-title` | `h1.top-card-layout__title` |
+| Company | `.job-details-jobs-v2__company-name` | `a.topcard__org-name-link` |
+| Location | `.job-details-jobs-v2__location` | `span.topcard__flavor--bullet` |
+| Applicants | `.job-details-jobs-v2__applicant-count` | `figure.num-applicants__figure` |
+| Description | `.show-more-less__description` | `div.description__text` |
+
+## Extraction Patterns (for Obscura `extract` with JS eval)
+
+Use these JavaScript expressions with `obscura_browse_session` action `extract` or `obscura_browse_page` with `eval`.
+
+### Extract all job cards from search results
+
+```js
+// Get all job card containers and extract their data
+Array.from(document.querySelectorAll('.job-card-container')).map(card => ({
+ title: card.querySelector('.job-card-list__title')?.textContent?.trim(),
+ company: card.querySelector('.job-card-list__company-name')?.textContent?.trim(),
+ location: card.querySelector('.job-card-list__location')?.textContent?.trim(),
+ applicants: card.querySelector('.job-card-list__applicant-count')?.textContent?.trim(),
+ url: card.querySelector('.job-card-list__title')?.href || card.querySelector('a')?.href,
+ recruiter: card.querySelector('.hirer-name')?.textContent?.trim(),
+}))
+```
+
+### Extract detail from a single job page (current selectors)
+
+```js
+// Extract all fields from the job detail page using current LinkedIn DOM
+({
+ title: document.querySelector('h1.top-card-layout__title')?.textContent?.trim(),
+ company: document.querySelector('a.topcard__org-name-link')?.textContent?.trim(),
+ location: document.querySelector('span.topcard__flavor--bullet')?.textContent?.trim(),
+ applicants: document.querySelector('figure.num-applicants__figure')?.textContent?.trim(),
+ description: document.querySelector('div.description__text')?.textContent?.trim(),
+ applyLinks: Array.from(document.querySelectorAll('.apply-link, .apply-button')).map(a => a.href || a.textContent),
+ recruiter: document.querySelector('.hirer-name')?.textContent?.trim(),
+ jobPostingDate: document.querySelector('span.job-posted-date')?.textContent?.trim(),
+ recruiterEmail: document.querySelector('.hirer-email')?.textContent?.trim(),
+ recruiterProfileLink: document.querySelector('a.hirer-name')?.href,
+})
+```
+
+### Fallback extraction (old selectors — kept for compatibility)
+
+```js
+// Fallback: old LinkedIn DOM selectors
+({
+ title: document.querySelector('.job-details-jobs-v2__main-job-title')?.textContent?.trim(),
+ company: document.querySelector('.job-details-jobs-v2__company-name')?.textContent?.trim(),
+ location: document.querySelector('.job-details-jobs-v2__location')?.textContent?.trim(),
+ applicants: document.querySelector('.job-details-jobs-v2__applicant-count')?.textContent?.trim(),
+ description: document.querySelector('.show-more-less__description')?.textContent?.trim(),
+ applyLinks: Array.from(document.querySelectorAll('.apply-link, .apply-button')).map(a => a.href || a.textContent),
+ recruiter: document.querySelector('.hirer-name')?.textContent?.trim(),
+ jobPostingDate: document.querySelector('.job-posted-date')?.textContent?.trim(),
+ recruiterEmail: document.querySelector('.hirer-email')?.textContent?.trim(),
+ recruiterProfileLink: document.querySelector('a.hirer-name')?.href,
+})
+```
+
+## Language Requirement Patterns
+
+These regex patterns (validated in `scripts/linkedin-extractor.mjs`) detect language requirements in job descriptions:
+
+### Required language indicators
+- `Required: {Language}`
+- `Requirements: {Language}`
+- `Must speak {Language}`
+- `Fluent in {Language}`
+- `{Language} is required`
+- `Proficient in {Language}`
+- `Mandatory: {Language}`
+- `Essential: {Language}`
+
+### Nice-to-have indicators
+- `Nice to have: {Language}`
+- `{Language} is a plus`
+- `{Language} is nice to have`
+- `Bonus: {Language}`
+- `Preferred: {Language}`
+- `Optional: {Language}`
+- `Good to have: {Language}`
+
+### Filter logic (validated in tests)
+
+A job **passes** the language filter if:
+1. No required languages are specified → pass
+2. All required languages match the user's specified language → pass
+3. Any required language does NOT match → skip
+
+A "nice to have" language is **not** a requirement — jobs with only nice-to-have languages that differ from the user's language still pass.
+
+## Known LinkedIn Behaviors
+
+- Job search results are dynamically loaded — wait for `.job-card-container` to appear after search
+- Some job descriptions are truncated with "Show more" — may need to click that button
+- "Easy Apply" jobs have an inline application form; external apply links go to the employer's site
+- Applicant count may be hidden or show "Be one of the first applicants"
+- Recruiter info is often absent or shows "Hiring team" instead of a named person
+- LinkedIn may show a login wall if the session cookie is not properly injected
diff --git a/skills/linkedin-job-search/references/public-listing-fallback.md b/skills/linkedin-job-search/references/public-listing-fallback.md
new file mode 100644
index 0000000..adafecf
--- /dev/null
+++ b/skills/linkedin-job-search/references/public-listing-fallback.md
@@ -0,0 +1,56 @@
+# LinkedIn public listing fallback for exact query searches
+
+Use this when the normal logged-in LinkedIn CDP runner repeatedly hits CAPTCHA/security pages but the user asks for a simple listing-level search such as `AI Architect AND remote`.
+
+## When to use
+
+- The runner logs repeated `[captcha] CAPTCHA detected on search page` and backs off/timeouts.
+- The user asked for an exact LinkedIn query and needs candidate listings quickly.
+- Full JD extraction/scoring can wait until after the shortlist is identified.
+
+## Pattern
+
+1. Open the public LinkedIn jobs search URL in the browser:
+
+```text
+https://www.linkedin.com/jobs/search/?keywords=&location=&f_TPR=r2592000
+```
+
+Example:
+
+```text
+https://www.linkedin.com/jobs/search/?keywords=AI%20Architect%20AND%20remote&location=Remote&f_TPR=r2592000
+```
+
+2. Dismiss any sign-in modal if visible. If a cookie dialog appears, choose the visible preference needed to expose listings.
+
+3. Extract listing cards from the DOM rather than relying on the logged-in runner. A useful browser-console expression is:
+
+```js
+Array.from(document.querySelectorAll('main li')).map(li => {
+ const jobLink = li.querySelector('a[href*="/jobs/view/"]');
+ const title = (li.querySelector('h3')?.innerText || jobLink?.innerText || '').trim();
+ const company = (li.querySelector('h4')?.innerText || '').trim();
+ const txt = (li.innerText || '').split('\n').map(s => s.trim()).filter(Boolean);
+ const loc = txt.find(s => s && s !== title && s !== company && !/ago$|applicant|benefits|insurance/i.test(s)) || '';
+ return { title, company, location: loc, text: txt.slice(0, 8), url: jobLink ? new URL(jobLink.href, location.href).href.split('?')[0] : '' };
+}).filter(x => x.title && x.url).slice(0, 30)
+```
+
+4. Save listing-level results to SQLite as `source='linkedin'` records with:
+
+- native LinkedIn numeric job id extracted from `/jobs/view/...-`
+- title, company, location, country code if inferable
+- `applicationLinks: [url]`
+- `descriptionText` and `descriptionRaw` empty if the JD was not fetched
+- `languageFilterReason` clearly noting listing-only extraction
+- `workModes: [{ mode: 'remote', isPrimary: true }]` only if the query or title/location explicitly indicates remote
+- `searchedKeywords` and `searchedLocation` set to the exact query context
+
+5. Tell the user the result is listing-level only. Do not claim JD/language/application-link extraction succeeded. The next step is detail fetch/backfill, then scoring, then application.
+
+## Pitfalls
+
+- Public LinkedIn may show a large count and visible listings even when the logged-in CDP runner is CAPTCHA-blocked. Do not stop at “runner blocked” if a public listing search can satisfy the user's immediate request.
+- Boolean-like query strings such as `AI Architect AND remote` may work well enough in LinkedIn's public keyword field, but treat the results as search-engine matches, not guaranteed Boolean semantics.
+- Avoid marking public listing fallback results as fully screened. They need JD fetch/backfill before `job-match-scorer` and `auto-job-application` can safely proceed.
diff --git a/skills/linkedin-job-search/references/us-remote-international-screening.md b/skills/linkedin-job-search/references/us-remote-international-screening.md
new file mode 100644
index 0000000..4e866f6
--- /dev/null
+++ b/skills/linkedin-job-search/references/us-remote-international-screening.md
@@ -0,0 +1,5 @@
+# International and Remote Screening
+
+Remote labels do not prove cross-border eligibility. Preserve the job's stated location, residency, citizenship, work-authorization, and sponsorship requirements as evidence for the scorer.
+
+Do not encode a maintainer's eligibility in the search skill. The scorer reads the installing user's `workAuthorization` cache and treats explicit no-sponsorship or citizenship requirements as blockers when applicable. Ambiguous requirements remain unresolved until verified.
diff --git a/skills/linkedin-job-search/schema.sql b/skills/linkedin-job-search/schema.sql
new file mode 100644
index 0000000..587c911
--- /dev/null
+++ b/skills/linkedin-job-search/schema.sql
@@ -0,0 +1,186 @@
+PRAGMA foreign_keys = ON;
+
+CREATE TABLE jobs (
+ source TEXT NOT NULL,
+ job_id TEXT NOT NULL,
+ url TEXT,
+ title TEXT,
+ company TEXT,
+ description_raw TEXT,
+ description_text TEXT,
+ location_raw TEXT,
+ country_code TEXT,
+ region TEXT,
+ city TEXT,
+ job_posting_date TEXT,
+ applicants_raw TEXT,
+ applicants_count INTEGER CHECK (applicants_count IS NULL OR applicants_count >= 0),
+ application_links_json TEXT,
+ recruiter TEXT,
+ recruiter_email TEXT,
+ recruiter_profile_link TEXT,
+ role_family_inferred TEXT,
+ role_family_confidence REAL CHECK (
+ role_family_confidence IS NULL
+ OR (role_family_confidence >= 0 AND role_family_confidence <= 1)
+ ),
+ role_family_reason TEXT,
+ language_filter_reason TEXT,
+ work_mode_reason TEXT,
+ searched_keywords TEXT,
+ searched_location TEXT,
+ application_status TEXT NOT NULL DEFAULT 'saved'
+ CHECK (application_status IN ('saved', 'applying', 'applied', 'failed', 'withdrawn')),
+ applied_at TEXT,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (source, job_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_jobs_application_status
+ON jobs(application_status, applied_at);
+
+CREATE TRIGGER jobs_touch_updated_at
+AFTER UPDATE ON jobs
+FOR EACH ROW
+WHEN NEW.updated_at = OLD.updated_at
+BEGIN
+ UPDATE jobs
+ SET updated_at = CURRENT_TIMESTAMP
+ WHERE source = NEW.source AND job_id = NEW.job_id;
+END;
+
+CREATE TABLE job_languages (
+ source TEXT NOT NULL,
+ job_id TEXT NOT NULL,
+ language TEXT NOT NULL,
+ importance TEXT NOT NULL CHECK (importance IN ('required', 'nice_to_have')),
+ PRIMARY KEY (source, job_id, language, importance),
+ FOREIGN KEY (source, job_id) REFERENCES jobs(source, job_id) ON DELETE CASCADE
+);
+
+CREATE TABLE job_work_modes (
+ source TEXT NOT NULL,
+ job_id TEXT NOT NULL,
+ work_mode TEXT NOT NULL CHECK (work_mode IN ('onsite', 'hybrid', 'remote')),
+ is_primary INTEGER NOT NULL DEFAULT 0 CHECK (is_primary IN (0, 1)),
+ PRIMARY KEY (source, job_id, work_mode),
+ FOREIGN KEY (source, job_id) REFERENCES jobs(source, job_id) ON DELETE CASCADE
+);
+
+CREATE TABLE users (
+ user_id TEXT PRIMARY KEY,
+ name TEXT,
+ default_stretch_tolerance TEXT NOT NULL DEFAULT 'medium' CHECK (
+ default_stretch_tolerance IN ('low', 'medium', 'high')
+ ),
+ current_country_code TEXT,
+ current_city TEXT,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE user_countries (
+ user_id TEXT NOT NULL,
+ country_code TEXT NOT NULL,
+ PRIMARY KEY (user_id, country_code),
+ FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
+);
+
+CREATE TABLE user_languages (
+ user_id TEXT NOT NULL,
+ language TEXT NOT NULL,
+ proficiency TEXT NOT NULL CHECK (
+ proficiency IN ('basic', 'conversational', 'professional', 'fluent', 'native')
+ ),
+ PRIMARY KEY (user_id, language),
+ FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
+);
+
+CREATE TABLE resumes (
+ resume_id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ label TEXT,
+ file_path TEXT NOT NULL,
+ file_language TEXT,
+ parsed_text TEXT,
+ is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
+);
+
+CREATE TABLE search_profiles (
+ search_id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ resume_id TEXT NOT NULL,
+ target_role_input TEXT NOT NULL,
+ target_role_confirmed TEXT,
+ role_family_suggestions_json TEXT,
+ stretch_tolerance_override TEXT CHECK (
+ stretch_tolerance_override IS NULL
+ OR stretch_tolerance_override IN ('low', 'medium', 'high')
+ ),
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
+ FOREIGN KEY (resume_id) REFERENCES resumes(resume_id) ON DELETE CASCADE
+);
+
+CREATE TABLE match_results (
+ search_id TEXT NOT NULL,
+ source TEXT NOT NULL,
+ job_id TEXT NOT NULL,
+ fit_score REAL NOT NULL CHECK (fit_score >= 0 AND fit_score <= 100),
+ cta TEXT NOT NULL CHECK (cta IN ('Apply', 'Maybe', 'Skip')),
+ stretch_label TEXT NOT NULL CHECK (
+ stretch_label IN ('Core fit', 'Stretch', 'Blocked')
+ ),
+ must_have_total INTEGER NOT NULL DEFAULT 0 CHECK (must_have_total >= 0),
+ must_have_matched INTEGER NOT NULL DEFAULT 0 CHECK (
+ must_have_matched >= 0 AND must_have_matched <= must_have_total
+ ),
+ nice_to_have_total INTEGER NOT NULL DEFAULT 0 CHECK (nice_to_have_total >= 0),
+ nice_to_have_matched INTEGER NOT NULL DEFAULT 0 CHECK (
+ nice_to_have_matched >= 0 AND nice_to_have_matched <= nice_to_have_total
+ ),
+ has_language_blocker INTEGER NOT NULL DEFAULT 0 CHECK (has_language_blocker IN (0, 1)),
+ has_country_mismatch INTEGER NOT NULL DEFAULT 0 CHECK (has_country_mismatch IN (0, 1)),
+ has_work_mode_mismatch INTEGER NOT NULL DEFAULT 0 CHECK (has_work_mode_mismatch IN (0, 1)),
+ tailoring_effort TEXT CHECK (tailoring_effort IN ('low', 'medium', 'high')),
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ matched_must_haves_json TEXT,
+ missing_or_unclear_must_haves_json TEXT,
+ matched_nice_to_haves_json TEXT,
+ tailoring_suggestions_json TEXT,
+ blockers_json TEXT,
+ PRIMARY KEY (search_id, source, job_id),
+ FOREIGN KEY (search_id) REFERENCES search_profiles(search_id) ON DELETE CASCADE,
+ FOREIGN KEY (source, job_id) REFERENCES jobs(source, job_id) ON DELETE CASCADE
+);
+
+CREATE UNIQUE INDEX ux_job_work_modes_primary
+ON job_work_modes(source, job_id)
+WHERE is_primary = 1;
+
+CREATE UNIQUE INDEX ux_resumes_default_per_user
+ON resumes(user_id)
+WHERE is_default = 1;
+
+CREATE INDEX idx_jobs_country_region_city
+ON jobs(country_code, region, city);
+
+CREATE INDEX idx_jobs_role_family
+ON jobs(role_family_inferred);
+
+CREATE INDEX idx_job_languages_language_importance
+ON job_languages(language, importance);
+
+CREATE INDEX idx_job_work_modes_mode
+ON job_work_modes(work_mode);
+
+CREATE INDEX idx_user_languages_language
+ON user_languages(language);
+
+CREATE INDEX idx_match_results_cta_score
+ON match_results(cta, fit_score DESC);
+
+CREATE INDEX idx_match_results_flags
+ON match_results(has_language_blocker, has_country_mismatch, has_work_mode_mismatch);
diff --git a/skills/linkedin-job-search/scripts/batch-fetch-jds.mjs b/skills/linkedin-job-search/scripts/batch-fetch-jds.mjs
new file mode 100644
index 0000000..69288d2
--- /dev/null
+++ b/skills/linkedin-job-search/scripts/batch-fetch-jds.mjs
@@ -0,0 +1,805 @@
+#!/usr/bin/env node
+import { Database } from '../../job-hunter/scripts/workspace-dependencies.mjs';
+/**
+ * Batch-backfill LinkedIn job descriptions already saved in SQLite.
+ *
+ * This is the skill-integrated version of the project-local helper that was
+ * previously run from a project-local scripts folder. It uses the
+ * browser-based Obscura CLI text path (not raw HTTP to LinkedIn), extracts the
+ * JD segment, refreshes language-filter decisions, and updates job_languages.
+ *
+ * Usage:
+ * node ../../linkedin-job-search/scripts/batch-fetch-jds.mjs \
+ * --db $PWD/jobhunter.sqlite \
+ * --speaks "English,Italian" \
+ * --exclude-languages "German,French" \
+ * --batch-size 5 --timeout 25
+ *
+ * The --db flag is optional; the script defaults to
+ * path.join(process.cwd(), 'jobhunter.sqlite') — i.e. the directory
+ * Pi Agent was launched from.
+ */
+
+import { execFileSync, spawn } from 'node:child_process';
+import path from 'node:path';
+
+import { startCdpKeepAlive } from './cdp-keepalive.mjs';
+import { detectLinkedInBlockPage } from './linkedin-page-state.mjs';
+
+const JOBHUNTER_HOME = process.env.JOBHUNTER_HOME || path.join(process.env.HOME || process.cwd(), '.job-hunter');
+const DEFAULT_DB = process.env.JOBHUNTER_DB || path.join(JOBHUNTER_HOME, 'jobhunter.sqlite');
+const DEFAULT_OBSCURA_BIN = process.env.OBSCURA_BIN || 'obscura';
+const DEFAULT_PORT = parsePositiveInt(process.env.LINKEDIN_CDP_PORT || process.env.BROWSER_CDP_PORT || process.env.OBSCURA_CDP_PORT || process.env.OBSCURA_PORT, 9225);
+const COMMON_OBSCURA_PORTS = [9225, 9222, 9224, 9226, 9227, 9228, 9230];
+
+function usage() {
+ console.log(`Usage:
+ batch-fetch-jds.mjs [options]
+
+Purpose:
+ Backfill description_text/description_raw and language_filter_reason for
+ LinkedIn jobs already saved in SQLite, using the active browser CDP text
+ fetch path. This is intended for jobs whose detail fetch timed out or whose
+ description was missing after a search run.
+
+Options:
+ --db SQLite DB (default ${DEFAULT_DB})
+ --source job source to process (default linkedin)
+ --speaks comma-separated languages user speaks (default English,Italian)
+ --exclude-languages comma-separated required languages that block (default German,French)
+ --batch-size concurrent detail fetches per batch (default 2)
+ --limit process at most n jobs
+ --timeout browser per-page timeout (default 25)
+ --wait browser wait before dump (default 2)
+ --keepalive-seconds CDP heartbeat interval; 0 disables (default 15)
+ --obscura-bin Obscura CLI binary, used only for --start-obscura (default obscura or $OBSCURA_BIN)
+ --port browser CDP port to use/check (default ${DEFAULT_PORT})
+ --obscura-port alias for --port
+ --start-obscura start 'obscura serve' only if no existing CDP is reachable
+ --retry-failed also retry prior JD extraction failures
+ --all process all saved source jobs, not only missing descriptions
+ --dry-run fetch and classify, but do not write SQLite
+ --help show this help
+
+Notes:
+ - LinkedIn pages are fetched through the existing browser CDP session.
+ - This script does not use curl/raw HTTP against LinkedIn or spawn per-page Obscura fetch processes.
+ - It updates normalized job_languages rows to match the refreshed JD parse.
+`);
+}
+
+function splitList(value) {
+ return String(value || '')
+ .split(/[;,]/)
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
+
+function parsePositiveInt(value, fallback) {
+ const n = Number.parseInt(value, 10);
+ return Number.isFinite(n) && n > 0 ? n : fallback;
+}
+
+function parseArgs(argv) {
+ const opts = {
+ db: DEFAULT_DB,
+ source: 'linkedin',
+ speaks: ['English', 'Italian'],
+ excludeLanguages: ['German', 'French'],
+ batchSize: 2,
+ limit: null,
+ timeout: 25,
+ wait: 2,
+ keepAliveSeconds: Math.max(0, Number(process.env.CDP_KEEPALIVE_SECONDS ?? 15) || 0),
+ obscuraBin: DEFAULT_OBSCURA_BIN,
+ port: DEFAULT_PORT,
+ startObscura: false,
+ retryFailed: false,
+ all: false,
+ dryRun: false,
+ };
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === '--help' || arg === '-h') {
+ usage();
+ process.exit(0);
+ } else if (arg === '--db') {
+ opts.db = argv[++i];
+ } else if (arg === '--source') {
+ opts.source = argv[++i] || opts.source;
+ } else if (arg === '--speaks') {
+ opts.speaks = splitList(argv[++i]);
+ } else if (arg === '--exclude-languages') {
+ opts.excludeLanguages = splitList(argv[++i]);
+ } else if (arg === '--batch-size') {
+ opts.batchSize = parsePositiveInt(argv[++i], opts.batchSize);
+ } else if (arg === '--limit') {
+ opts.limit = parsePositiveInt(argv[++i], opts.limit || 0) || null;
+ } else if (arg === '--timeout') {
+ opts.timeout = parsePositiveInt(argv[++i], opts.timeout);
+ } else if (arg === '--wait') {
+ opts.wait = parsePositiveInt(argv[++i], opts.wait);
+ } else if (arg === '--keepalive-seconds') {
+ opts.keepAliveSeconds = Math.max(0, Number(argv[++i]) || 0);
+ } else if (arg === '--obscura-bin') {
+ opts.obscuraBin = argv[++i] || opts.obscuraBin;
+ } else if (arg === '--port' || arg === '--obscura-port') {
+ opts.port = parsePositiveInt(argv[++i], opts.port);
+ } else if (arg === '--start-obscura') {
+ opts.startObscura = true;
+ } else if (arg === '--retry-failed') {
+ opts.retryFailed = true;
+ } else if (arg === '--all') {
+ opts.all = true;
+ } else if (arg === '--dry-run') {
+ opts.dryRun = true;
+ } else {
+ console.error(`Unknown argument: ${arg}`);
+ usage();
+ process.exit(2);
+ }
+ }
+
+ if (!opts.speaks.length) {
+ console.error('At least one spoken language is required via --speaks.');
+ process.exit(2);
+ }
+ return opts;
+}
+
+function shQuiet(cmd, args, options = {}) {
+ return execFileSync(cmd, args, { stdio: 'ignore', ...options });
+}
+
+function readCdpVersion(port) {
+ try {
+ const out = execFileSync('curl', ['-sS', '-f', '--max-time', '3', `http://127.0.0.1:${port}/json/version`], { encoding: 'utf8', timeout: 5000 });
+ return JSON.parse(out);
+ } catch {
+ return null;
+ }
+}
+
+function uniq(values) {
+ return [...new Set(values.filter(Boolean))];
+}
+
+function candidateObscuraPorts(preferred) {
+ return uniq([
+ preferred,
+ process.env.OBSCURA_CDP_PORT,
+ process.env.OBSCURA_PORT,
+ DEFAULT_PORT,
+ ...COMMON_OBSCURA_PORTS,
+ ].map((value) => parsePositiveInt(value, null)).filter(Boolean));
+}
+
+function findExistingObscuraCdp(preferred) {
+ for (const port of candidateObscuraPorts(preferred)) {
+ const version = readCdpVersion(port);
+ if (version?.webSocketDebuggerUrl) return { port, version };
+ }
+ return null;
+}
+
+function sleepSync(ms) {
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
+}
+
+function ensureObscura(opts) {
+ const existing = findExistingObscuraCdp(opts.port);
+ if (existing) {
+ opts.port = existing.port;
+ console.log(`Using existing Docker Chromium/CDP browser on 127.0.0.1:${opts.port}.`);
+ return opts.port;
+ }
+ if (!opts.startObscura) {
+ const ports = candidateObscuraPorts(opts.port).join(', ');
+ throw new Error(`Browser CDP is not reachable on checked port(s): ${ports}. Start the Docker Chromium session, pass --port, or use --start-obscura only for the optional legacy fallback.`);
+ }
+
+ console.log(`Starting Obscura on port ${opts.port}...`);
+ const child = spawn(opts.obscuraBin, ['serve', '-p', String(opts.port), '--stealth', '--workers', '8'], {
+ detached: true,
+ stdio: 'ignore',
+ });
+ child.unref();
+
+ for (let i = 0; i < 30; i++) {
+ if (readCdpVersion(opts.port)?.webSocketDebuggerUrl) {
+ console.log(`Obscura started on 127.0.0.1:${opts.port}.`);
+ return opts.port;
+ }
+ sleepSync(500);
+ }
+ throw new Error(`Timed out waiting for Obscura on 127.0.0.1:${opts.port}.`);
+}
+
+function withTimeout(promise, ms, label) {
+ let timer;
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
+ });
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function randomDelayMs(minSeconds = 1, maxSeconds = 2) {
+ const min = Math.min(minSeconds, maxSeconds);
+ const max = Math.max(minSeconds, maxSeconds);
+ return Math.round((min + Math.random() * (max - min)) * 1000);
+}
+
+// ── Anti-detection utilities ──────────────────────────────────────────────
+
+function jitter(min, max) {
+ return min + Math.random() * (max - min);
+}
+
+async function humanDelay(context, minMs, maxMs) {
+ const ms = Math.round(jitter(minMs, maxMs));
+ console.log(` [human] ${context} (${(ms / 1000).toFixed(1)}s)`);
+ await sleep(ms);
+}
+
+const ANTI_DETECT_EXPR = `(() => {
+ try {
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
+ window.chrome = { runtime: {} };
+ const origQuery = navigator.permissions.query;
+ navigator.permissions.query = (p) => p.name === 'notifications'
+ ? Promise.resolve({ state: Notification.permission })
+ : origQuery(p);
+ Object.defineProperty(navigator, 'plugins', { get: () => ({ length: 5 }) });
+ Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
+ Object.defineProperty(navigator, 'platform', { get: () => 'MacIntel' });
+ return { ok: true };
+ } catch(e) { return { ok: false, error: e.message }; }
+})()`;
+
+async function injectAntiDetection(client, sessionId) {
+ try {
+ const result = await withTimeout(
+ client.send('Runtime.evaluate', {
+ expression: ANTI_DETECT_EXPR,
+ returnByValue: true,
+ awaitPromise: true,
+ }, sessionId),
+ 5000,
+ 'inject anti-detect'
+ );
+ return result?.result?.value?.ok === true;
+ } catch {
+ return false;
+ }
+}
+
+async function simulateMouseMovement(client, sessionId) {
+ const moves = 3 + Math.floor(Math.random() * 4);
+ const vw = 800 + Math.floor(Math.random() * 400);
+ const vh = 500 + Math.floor(Math.random() * 300);
+ for (let i = 0; i < moves; i++) {
+ const x = Math.floor(jitter(vw * 0.1, vw * 0.9));
+ const y = Math.floor(jitter(vh * 0.1, vh * 0.7));
+ await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }, sessionId).catch(() => {});
+ await sleep(Math.round(jitter(150, 400)));
+ }
+}
+
+async function simulateScroll(client, sessionId) {
+ const scrolls = 3 + Math.floor(Math.random() * 3);
+ await client.send('Runtime.evaluate', {
+ expression: `(async () => {
+ for (let i = 0; i < ${scrolls}; i++) {
+ const amount = ${200 + Math.floor(Math.random() * 500)};
+ window.scrollBy({ top: amount, behavior: 'smooth' });
+ await new Promise(r => setTimeout(r, ${200 + Math.floor(Math.random() * 300)}));
+ }
+ return 'ok';
+ })()`,
+ awaitPromise: true,
+ returnByValue: true,
+ }, sessionId).catch(() => {});
+}
+
+const detectBlockPage = detectLinkedInBlockPage;
+
+function backoffDelay(attempt, baseMs = 10000, maxMs = 120000) {
+ return Math.round(Math.min(baseMs * Math.pow(2, Math.min(attempt, 4)) + jitter(0, 3000), maxMs));
+}
+
+const rateTracker = { requests: [], windowMs: 60000, maxRequests: 12 };
+
+function noteRequest() {
+ const now = Date.now();
+ rateTracker.requests.push(now);
+ while (rateTracker.requests.length && rateTracker.requests[0] < now - rateTracker.windowMs) {
+ rateTracker.requests.shift();
+ }
+}
+
+async function enforceRateLimit() {
+ noteRequest();
+ while (rateTracker.requests.length > rateTracker.maxRequests) {
+ const oldest = rateTracker.requests[0];
+ const waitMs = oldest + rateTracker.windowMs - Date.now() + 1000;
+ if (waitMs > 0) {
+ console.log(` [rate-limit] ${rateTracker.requests.length} requests in window, pausing ${(waitMs / 1000).toFixed(1)}s`);
+ await sleep(waitMs);
+ }
+ const now = Date.now();
+ while (rateTracker.requests.length && rateTracker.requests[0] < now - rateTracker.windowMs) {
+ rateTracker.requests.shift();
+ }
+ }
+}
+
+class CdpClient {
+ constructor(wsUrl) {
+ this.wsUrl = wsUrl;
+ this.seq = 0;
+ this.pending = new Map();
+ }
+
+ async connect() {
+ this.ws = new WebSocket(this.wsUrl);
+ await new Promise((resolve, reject) => {
+ this.ws.onopen = resolve;
+ this.ws.onerror = reject;
+ });
+ this.ws.onmessage = (ev) => {
+ const msg = JSON.parse(ev.data);
+ if (msg.id && this.pending.has(msg.id)) {
+ const p = this.pending.get(msg.id);
+ this.pending.delete(msg.id);
+ msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result);
+ }
+ };
+ }
+
+ send(method, params = {}, sessionId) {
+ return new Promise((resolve, reject) => {
+ const id = ++this.seq;
+ this.pending.set(id, { resolve, reject });
+ const msg = { id, method, params };
+ if (sessionId) msg.sessionId = sessionId;
+ this.ws.send(JSON.stringify(msg));
+ });
+ }
+
+ close() {
+ try { this.ws?.close(); } catch {}
+ }
+}
+
+async function connectCdp(opts) {
+ const version = readCdpVersion(opts.port);
+ if (!version?.webSocketDebuggerUrl) throw new Error(`Browser CDP is not reachable on 127.0.0.1:${opts.port}`);
+ const client = new CdpClient(version.webSocketDebuggerUrl);
+ await client.connect();
+ return client;
+}
+
+async function mapLimit(items, limit, fn, interDelayMs = 0) {
+ const out = new Array(items.length);
+ let next = 0;
+ const workers = Array.from({ length: Math.min(limit, items.length || 1) }, async () => {
+ while (true) {
+ const i = next++;
+ if (i >= items.length) return;
+ if (interDelayMs > 0 && i > 0) {
+ const delayMs = Math.round(jitter(interDelayMs * 0.5, interDelayMs * 1.5));
+ await sleep(delayMs);
+ }
+ out[i] = await fn(items[i], i);
+ }
+ });
+ await Promise.all(workers);
+ return out;
+}
+
+async function fetchJobText(job, opts, client) {
+ const url = job.url || `https://www.linkedin.com/jobs/view/${job.job_id}`;
+ let targetId;
+ try {
+ const timeoutMs = Math.max(5000, Number(opts.timeout || 25) * 1000);
+ const target = await withTimeout(client.send('Target.createTarget', { url: 'about:blank' }), 10000, 'Target.createTarget');
+ targetId = target.targetId;
+ const attached = await withTimeout(client.send('Target.attachToTarget', { targetId, flatten: true }), 10000, 'Target.attachToTarget');
+ const sessionId = attached.sessionId;
+ await withTimeout(client.send('Page.enable', {}, sessionId), 8000, 'Page.enable');
+ await withTimeout(client.send('Runtime.enable', {}, sessionId), 8000, 'Runtime.enable');
+ await injectAntiDetection(client, sessionId);
+ await humanDelay('batch-detail nav', 2000, 5000);
+ const nav = await withTimeout(client.send('Page.navigate', { url, referrer: 'https://www.linkedin.com/jobs/search/' }, sessionId), timeoutMs, 'Page.navigate');
+ if (nav.errorText) throw new Error(nav.errorText);
+
+ await simulateMouseMovement(client, sessionId);
+ await simulateScroll(client, sessionId);
+ await humanDelay('batch-detail read', 3000, 6000);
+
+ let text = '';
+ for (let i = 0; i < 18; i++) {
+ if (i > 0) await humanDelay('batch-detail retry', 2000, 4000);
+ const result = await withTimeout(client.send('Runtime.evaluate', {
+ expression: 'document.body ? document.body.innerText : document.documentElement.innerText || ""',
+ returnByValue: true,
+ awaitPromise: true,
+ }, sessionId), timeoutMs, 'Runtime.evaluate');
+ text = String(result.result?.value || '');
+ const block = detectBlockPage(text);
+ if (block.blocked) {
+ return { ok: true, text, url, blocked: true, isCaptcha: block.isCaptcha, blockReason: block.reason };
+ }
+ if (text.includes('Report this job') || text.includes('Seniority level') || text.includes('About the job')) break;
+ }
+ if (!text) throw new Error('empty detail page text');
+ return { ok: true, text, url, blocked: false, isCaptcha: false, blockReason: null };
+ } catch (e) {
+ return { ok: false, text: '', url, error: e.message, blocked: false, isCaptcha: false, blockReason: null };
+ } finally {
+ if (targetId) await client.send('Target.closeTarget', { targetId }).catch(() => {});
+ }
+}
+
+function publicTextLines(text) {
+ const lines = [];
+ for (const part of String(text || '').split(/\n+/)) {
+ const line = part.trim().replace(/\s+/g, ' ');
+ if (!line) continue;
+ if (line === lines[lines.length - 1]) continue;
+ if (/^window\.__/.test(line)) continue;
+ lines.push(line);
+ }
+ return lines;
+}
+
+function extractDescriptionFromLines(lines) {
+ const start = lines.findIndex((line) => line === 'Report this job');
+ const end = lines.findIndex((line, index) => index > start && line === 'Seniority level');
+ let descLines = start >= 0 && end > start ? lines.slice(start + 1, end) : [];
+ if (!descLines.length) {
+ const aboutStart = lines.findIndex((line) => /^About the job$/i.test(line));
+ const aboutEnds = [/^Show less$/i, /^Show more$/i, /^Seniority level$/i, /^Employment type$/i, /^Job function$/i, /^Industries$/i, /^Skills$/i, /^Similar jobs$/i, /^People also viewed$/i, /^Set alert/i];
+ const aboutEnd = lines.findIndex((line, index) => index > aboutStart && aboutEnds.some((re) => re.test(line)));
+ if (aboutStart >= 0) descLines = lines.slice(aboutStart + 1, aboutEnd > aboutStart ? aboutEnd : undefined);
+ }
+
+ const actualStart = descLines.findIndex((line) => /^(About|Responsibilities|Requirements|Qualifications|Job description|The Role|Role Overview|Your role|What you|We are|We’re|Our client|Company Description|Minimum qualifications|Basic qualifications|Overview|Locations?|Aufgaben|Profil|Deine|Ihre|Your mission)/i.test(line));
+ if (actualStart > 0) descLines = descLines.slice(actualStart);
+
+ return descLines.join('\n').trim();
+}
+
+function extractDescriptionByMarkers(text) {
+ const markers = [
+ ['Report this job', 'Seniority level'],
+ ['Report this job', 'Employment type'],
+ ['Report this job', 'Show more'],
+ ['About this job', 'Show more'],
+ ['About this job', 'Show less'],
+ ['About the job', 'Show more'],
+ ['About the job', 'Show less'],
+ ['Job description', 'Show more'],
+ ];
+
+ for (const [startMarker, endMarker] of markers) {
+ const start = text.indexOf(startMarker);
+ const end = text.indexOf(endMarker, start + startMarker.length);
+ if (start >= 0 && end > start) {
+ const jd = cleanDescription(text.slice(start + startMarker.length, end));
+ if (jd.length > 100) return jd;
+ }
+ }
+ return null;
+}
+
+function cleanDescription(text) {
+ return String(text || '')
+ .replace(/\b(?:Join now|Sign in|Apply|Easy Apply|Save)\b/g, ' ')
+ .replace(/\n{3,}/g, '\n\n')
+ .replace(/[ \t]{2,}/g, ' ')
+ .trim();
+}
+
+function extractJD(text) {
+ if (!text || text.length < 100) return null;
+ const lines = publicTextLines(text);
+ const fromLines = cleanDescription(extractDescriptionFromLines(lines));
+ if (fromLines.length > 100) return fromLines;
+ return extractDescriptionByMarkers(text);
+}
+
+function escapeRe(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function sentenceSplit(text) {
+ return String(text || '')
+ .replace(/\n+/g, '. ')
+ .split(/(?<=[.!?;])\s+|\s+[-–—]\s+/)
+ .map((part) => part.trim())
+ .filter(Boolean);
+}
+
+const LANGUAGE_MAP = {
+ English: ['English', 'Englisch', 'anglais', 'inglese'],
+ Italian: ['Italian', 'Italienisch', 'italien', 'italiano'],
+ German: ['German', 'Deutsch', 'Allemand', 'Tedesco', 'Deutschkenntnisse'],
+ French: ['French', 'Français', 'Francais', 'Französisch', 'Franzoesisch', 'francese'],
+ Spanish: ['Spanish', 'Spanisch', 'Espagnol', 'Spagnolo'],
+ Dutch: ['Dutch', 'Niederländisch', 'Nederlands'],
+ Portuguese: ['Portuguese', 'Portugiesisch', 'Portugais'],
+};
+
+const NICE_CONTEXT = /\b(nice[- ]to[- ]have|plus|bonus|preferred|advantage|asset|optional|beneficial|would be a plus|good to have|desirable)\b/i;
+const REQUIRED_CONTEXT = /\b(required|must|mandatory|essential|fluent|fluency|proficient|excellent|strong|native|business fluent|c1|c2|kenntnisse|maîtrise|maitrise|obligatoire|nécessaire|necessaire|erforderlich|voraussetzung|zwingend)\b/i;
+const LANGUAGE_CONTEXT = /\b(language skills|sprachkenntnisse|langues?|languages?|written and spoken|spoken and written)\b/i;
+
+function parseLanguages(description) {
+ const required = new Set();
+ const niceToHave = new Set();
+
+ for (const sentence of sentenceSplit(description)) {
+ for (const [language, aliases] of Object.entries(LANGUAGE_MAP)) {
+ if (!aliases.some((alias) => new RegExp(`\\b${escapeRe(alias)}\\b`, 'i').test(sentence))) continue;
+ if (NICE_CONTEXT.test(sentence) && !REQUIRED_CONTEXT.test(sentence.replace(NICE_CONTEXT, ''))) {
+ niceToHave.add(language);
+ } else if (REQUIRED_CONTEXT.test(sentence) || LANGUAGE_CONTEXT.test(sentence)) {
+ required.add(language);
+ }
+ }
+ }
+
+ return {
+ required: [...required],
+ niceToHave: [...niceToHave].filter((language) => !required.has(language)),
+ };
+}
+
+function canonicalLanguage(value) {
+ const text = String(value || '').trim().toLowerCase();
+ for (const [language, aliases] of Object.entries(LANGUAGE_MAP)) {
+ if (language.toLowerCase() === text || aliases.some((alias) => alias.toLowerCase() === text)) return language;
+ }
+ return text ? text.charAt(0).toUpperCase() + text.slice(1) : null;
+}
+
+function checkLanguageFilter(requirements, opts) {
+ const speaks = new Set(opts.speaks.map((language) => canonicalLanguage(language)?.toLowerCase()).filter(Boolean));
+ const blockedExplicit = new Set(opts.excludeLanguages.map((language) => canonicalLanguage(language)?.toLowerCase()).filter(Boolean));
+ const missing = requirements.required.filter((language) => !speaks.has(language.toLowerCase()));
+ const blocked = requirements.required.filter((language) => blockedExplicit.has(language.toLowerCase()));
+
+ if (!requirements.required.length) {
+ return { pass: true, reason: 'PASS: No language requirements detected in JD' };
+ }
+ if (blocked.length) {
+ return { pass: false, reason: `FAIL: JD requires ${blocked.join(', ')} (user does not speak it/them)` };
+ }
+ if (missing.length) {
+ return { pass: false, reason: `FAIL: JD requires ${missing.join(', ')} (not in user spoken languages)` };
+ }
+ return { pass: true, reason: `PASS: JD requires ${requirements.required.join(', ')} (user speaks all)` };
+}
+
+const BLOCKED_TITLE_RE = /\b(architekt(?:in)?|architecte|architetto|projektleiter(?:in)?|zeichner(?:in)?|bauleiter(?:in)?|innenarchitekt|architecte d.intérieur|architetto d.interni|praktikant(?:in)?)\b/i;
+const AI_SOFTWARE_RE = /\b(ai|a\.i\.|artificial intelligence|genai|generative ai|llm|machine learning|ml\b|data\s*&\s*ai|data and ai|ai\s*&\s*data|solution architect|solutions architect|enterprise architect|software architect|cloud architect)\b/i;
+
+function checkTitle(title) {
+ const text = String(title || '');
+ if (BLOCKED_TITLE_RE.test(text) && !AI_SOFTWARE_RE.test(text)) {
+ return { pass: false, reason: `FAIL: Title appears to be a non-IT/building architect role (${text})` };
+ }
+ return { pass: true, reason: null };
+}
+
+function buildJobQuery(opts) {
+ const where = ['source = @source'];
+ if (!opts.all) {
+ where.push(`(
+ description_text IS NULL
+ OR trim(description_text) = ''
+ ${opts.retryFailed ? "OR language_filter_reason LIKE 'FAIL: Could not extract JD%' OR language_filter_reason LIKE 'FAIL: Error fetching page:%' OR language_filter_reason LIKE 'scrape_failed:%'" : ''}
+ )`);
+ }
+ const limitSql = opts.limit ? 'LIMIT @limit' : '';
+ return `
+ SELECT source, job_id, url, title, company, location_raw, language_filter_reason
+ FROM jobs
+ WHERE ${where.join(' AND ')}
+ ORDER BY updated_at DESC, title COLLATE NOCASE
+ ${limitSql}
+ `;
+}
+
+function ensureTables(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS job_languages (
+ source TEXT NOT NULL,
+ job_id TEXT NOT NULL,
+ language TEXT NOT NULL,
+ importance TEXT NOT NULL CHECK (importance IN ('required', 'nice_to_have')),
+ PRIMARY KEY (source, job_id, language, importance),
+ FOREIGN KEY (source, job_id) REFERENCES jobs(source, job_id) ON DELETE CASCADE
+ );
+ `);
+}
+
+function makeUpdater(db) {
+ const updateJob = db.prepare(`
+ UPDATE jobs
+ SET description_text = ?,
+ description_raw = ?,
+ language_filter_reason = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE source = ? AND job_id = ?
+ `);
+ const deleteLanguages = db.prepare('DELETE FROM job_languages WHERE source = ? AND job_id = ?');
+ const insertLanguage = db.prepare(`
+ INSERT OR IGNORE INTO job_languages (source, job_id, language, importance)
+ VALUES (?, ?, ?, ?)
+ `);
+
+ return db.transaction((job, descriptionText, descriptionRaw, languageFilterReason, requirements) => {
+ updateJob.run(descriptionText, descriptionRaw, languageFilterReason, job.source, job.job_id);
+ deleteLanguages.run(job.source, job.job_id);
+ for (const language of requirements.required || []) {
+ insertLanguage.run(job.source, job.job_id, language, 'required');
+ }
+ for (const language of requirements.niceToHave || []) {
+ insertLanguage.run(job.source, job.job_id, language, 'nice_to_have');
+ }
+ });
+}
+
+async function processJob(job, opts, client) {
+ let blockAttempt = 0;
+ while (true) {
+ await enforceRateLimit();
+ const fetched = await fetchJobText(job, opts, client);
+
+ if (fetched.blocked) {
+ blockAttempt++;
+ if (fetched.isCaptcha) {
+ console.warn(` [captcha] CAPTCHA on detail ${job.job_id}: ${fetched.blockReason}`);
+ } else {
+ console.warn(` [blocked] LinkedIn warning on detail ${job.job_id}: ${fetched.blockReason}`);
+ }
+ const backoff = backoffDelay(blockAttempt, 15000, 120000);
+ console.warn(` [batch-backoff] ${job.job_id} attempt=${blockAttempt} waiting ${(backoff / 1000).toFixed(1)}s`);
+ await sleep(backoff);
+ continue;
+ }
+
+ const jd = extractJD(fetched.text);
+ const requirements = jd ? parseLanguages(jd) : { required: [], niceToHave: [] };
+
+ if (!fetched.ok) {
+ return {
+ job,
+ jd: null,
+ raw: fetched.text ? fetched.text.slice(0, 10000) : null,
+ requirements,
+ pass: false,
+ reason: `FAIL: Error fetching page: ${fetched.error}`,
+ };
+ }
+
+ if (!jd) {
+ return {
+ job,
+ jd: null,
+ raw: fetched.text ? fetched.text.slice(0, 10000) : null,
+ requirements,
+ pass: false,
+ reason: 'FAIL: Could not extract JD from page',
+ };
+ }
+
+ const titleCheck = checkTitle(job.title);
+ if (!titleCheck.pass) {
+ return {
+ job,
+ jd,
+ raw: jd,
+ requirements,
+ pass: false,
+ reason: titleCheck.reason,
+ };
+ }
+
+ const languageCheck = checkLanguageFilter(requirements, opts);
+ const suffix = requirements.niceToHave.length ? ` Nice-to-have: ${requirements.niceToHave.join(', ')}.` : '';
+ return {
+ job,
+ jd,
+ raw: jd,
+ requirements,
+ pass: languageCheck.pass,
+ reason: `${languageCheck.reason}.${suffix}`.replace('..', '.'),
+ };
+ }
+}
+
+async function main() {
+ const opts = parseArgs(process.argv.slice(2));
+ console.log('Batch JD backfill started');
+ console.log(`DB: ${opts.db}`);
+ console.log(`Source: ${opts.source}`);
+ console.log(`Speaks: ${opts.speaks.join(', ')}`);
+ console.log(`Blocked required languages: ${opts.excludeLanguages.join(', ') || '(none)'}`);
+ console.log(`Batch size: ${opts.batchSize}; timeout: ${opts.timeout}s; dry-run: ${opts.dryRun ? 'yes' : 'no'}`);
+
+ ensureObscura(opts);
+ const client = await connectCdp(opts);
+ const keepAlive = startCdpKeepAlive(client, {
+ intervalMs: opts.keepAliveSeconds * 1000,
+ label: 'LinkedIn JD CDP',
+ onStart: ({ intervalMs }) => console.log(`[cdp] heartbeat enabled every ${intervalMs / 1000}s on 127.0.0.1:${opts.port}`),
+ onFailure: (error) => console.warn(`[cdp] heartbeat failed: ${error.message}`),
+ });
+
+ const db = new Database(opts.db);
+ ensureTables(db);
+ const jobs = db.prepare(buildJobQuery(opts)).all({ source: opts.source, limit: opts.limit });
+ console.log(`Found ${jobs.length} job(s) to process.`);
+
+ if (!jobs.length) {
+ keepAlive.stop();
+ client.close();
+ db.close();
+ return;
+ }
+
+ const update = makeUpdater(db);
+ const totals = { processed: 0, passed: 0, failed: 0, errors: 0, written: 0 };
+
+ for (let i = 0; i < jobs.length; i += opts.batchSize) {
+ const batch = jobs.slice(i, i + opts.batchSize);
+ console.log(`\nBatch ${Math.floor(i / opts.batchSize) + 1}: fetching ${batch.length} job detail page(s)...`);
+ const results = await mapLimit(batch, opts.batchSize, (job) => processJob(job, opts, client), 5000);
+
+ for (const result of results) {
+ totals.processed++;
+ if (result.pass) totals.passed++;
+ else totals.failed++;
+ if (result.reason.startsWith('FAIL: Error fetching page:')) totals.errors++;
+
+ if (!opts.dryRun) {
+ update(result.job, result.jd, result.raw, result.reason, result.requirements);
+ totals.written++;
+ }
+
+ const mark = result.pass ? '✓ PASS' : '✗ FAIL';
+ const title = result.job.title || result.job.job_id;
+ const company = result.job.company ? ` @ ${result.job.company}` : '';
+ const langs = [...result.requirements.required.map((l) => `${l}:required`), ...result.requirements.niceToHave.map((l) => `${l}:nice`)];
+ const langText = langs.length ? ` [${langs.join(', ')}]` : '';
+ console.log(` [${totals.processed}/${jobs.length}] ${mark} ${title}${company} — ${result.reason}${langText}`);
+ }
+
+ if (i + opts.batchSize < jobs.length) {
+ await humanDelay('between batches', 5000, 10000);
+ }
+ }
+
+ keepAlive.stop();
+ client.close();
+ db.close();
+ console.log('\n=== Summary ===');
+ console.log(`Total processed: ${totals.processed}`);
+ console.log(`Passed language filter: ${totals.passed}`);
+ console.log(`Failed language/title/JD filter: ${totals.failed}`);
+ console.log(`Fetch errors: ${totals.errors}`);
+ console.log(`SQLite rows updated: ${totals.written}`);
+}
+
+main().catch((error) => {
+ console.error(error?.stack || error?.message || String(error));
+ process.exit(1);
+});
diff --git a/skills/linkedin-job-search/scripts/cdp-keepalive.mjs b/skills/linkedin-job-search/scripts/cdp-keepalive.mjs
new file mode 100644
index 0000000..9c31cab
--- /dev/null
+++ b/skills/linkedin-job-search/scripts/cdp-keepalive.mjs
@@ -0,0 +1,52 @@
+function withTimeout(promise, ms, label) {
+ let timer;
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
+ timer.unref?.();
+ });
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
+}
+
+export function startCdpKeepAlive(client, options = {}) {
+ const intervalMs = Math.max(0, Number(options.intervalMs ?? 15000));
+ const timeoutMs = Math.max(1000, Number(options.timeoutMs ?? 5000));
+ const label = options.label || 'CDP';
+ const stats = { sent: 0, failed: 0, lastSuccessAt: null, lastError: null };
+ let stopped = false;
+ let busy = false;
+ let timer = null;
+
+ const beat = async () => {
+ if (stopped || busy) return;
+ busy = true;
+ try {
+ await withTimeout(client.send('Browser.getVersion', {}), timeoutMs, `${label} keepalive`);
+ stats.sent++;
+ stats.lastSuccessAt = new Date().toISOString();
+ stats.lastError = null;
+ } catch (error) {
+ stats.failed++;
+ stats.lastError = error?.message || String(error);
+ options.onFailure?.(error, { ...stats });
+ } finally {
+ busy = false;
+ }
+ };
+
+ if (intervalMs > 0) {
+ void beat();
+ timer = setInterval(() => { void beat(); }, intervalMs);
+ timer.unref?.();
+ options.onStart?.({ intervalMs, timeoutMs });
+ }
+
+ return {
+ stats,
+ async beat() { await beat(); },
+ stop() {
+ stopped = true;
+ if (timer) clearInterval(timer);
+ timer = null;
+ },
+ };
+}
diff --git a/skills/linkedin-job-search/scripts/cdp-lease.mjs b/skills/linkedin-job-search/scripts/cdp-lease.mjs
new file mode 100644
index 0000000..31d4757
--- /dev/null
+++ b/skills/linkedin-job-search/scripts/cdp-lease.mjs
@@ -0,0 +1,469 @@
+/**
+ * Cross-process CDP coordination: lease + shared request budget + tab registry.
+ *
+ * All state is stored in files under a lock directory (default ~/.job-hunter/locks/).
+ * Every operation is fail-open: a corrupt/missing file never crashes the caller.
+ *
+ * LEASE
+ * const h = acquireLease({ lockDir, leaseName: 'linkedin-search:9225', runId: 'x' });
+ * refreshHeartbeat(h);
+ * registerTab(h, targetId);
+ * releaseLease(h); // call on cleanup
+ *
+ * Stale leases (heartbeat > 60 s, or dead PID) may be taken over by a new process.
+ *
+ * SHARED BUDGET
+ * const b = createSharedBudget({ lockDir, budgetName: 'linkedin.com:9225', windowMs: 60000, maxRequests: 12 });
+ * await b.waitForSlot(); // sleep until a slot is available
+ * // On cleanup: b.destroy() (optional)
+ *
+ * Budged file corruption or I/O errors → warn and fail open (caller proceeds).
+ */
+
+import {
+ closeSync,
+ existsSync,
+ mkdirSync,
+ openSync,
+ readFileSync,
+ unlinkSync,
+ writeFileSync,
+} from 'node:fs';
+import { homedir, tmpdir } from 'node:os';
+import path from 'node:path';
+
+// ── Helpers ──────────────────────────────────────────────────────────
+
+function sleep(ms) {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+function pidAlive(pid) {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function ensureDir(dir) {
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
+}
+
+function safeReadJson(filePath) {
+ try {
+ if (!existsSync(filePath)) return null;
+ return JSON.parse(readFileSync(filePath, 'utf8'));
+ } catch {
+ return null;
+ }
+}
+
+function safeWriteJson(filePath, obj) {
+ try {
+ writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8');
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function safeUnlink(filePath) {
+ try {
+ if (existsSync(filePath)) unlinkSync(filePath);
+ } catch {
+ // best-effort
+ }
+}
+
+/**
+ * Atomic file creation: returns true if the file was newly created, false
+ * if it already existed. Uses the O_EXCL | O_CREAT trick on Unix.
+ */
+function atomicCreate(filePath, content) {
+ let fd;
+ try {
+ fd = openSync(filePath, 'wx');
+ writeFileSync(fd, content, 'utf8');
+ return true;
+ } catch (err) {
+ if (err.code === 'EEXIST') return false;
+ throw err;
+ } finally {
+ if (fd !== undefined) {
+ try { closeSync(fd); } catch {}
+ }
+ }
+}
+
+// ── Mutex (advisory, file-based) ─────────────────────────────────────
+
+const MUTEX_RETRY_MS = 50;
+const MUTEX_MAX_WAIT_MS = 3000;
+
+async function withMutex(lockFile, fn) {
+ const started = Date.now();
+ while (true) {
+ if (atomicCreate(lockFile, String(process.pid))) {
+ try {
+ return await fn();
+ } finally {
+ safeUnlink(lockFile);
+ }
+ }
+ // Break stale mutex left by a crashed process (dead PID).
+ try {
+ const holderPid = parseInt(readFileSync(lockFile, 'utf8').trim(), 10);
+ if (Number.isInteger(holderPid) && holderPid > 0 && !pidAlive(holderPid)) {
+ safeUnlink(lockFile);
+ continue;
+ }
+ } catch {
+ // Lockfile vanished between create attempt and read — retry.
+ continue;
+ }
+ if (Date.now() - started > MUTEX_MAX_WAIT_MS) {
+ throw new Error(`mutex timeout on ${lockFile}`);
+ }
+ await sleep(MUTEX_RETRY_MS + Math.random() * 30);
+ }
+}
+
+// ── Lease ────────────────────────────────────────────────────────────
+
+const DEFAULT_HEARTBEAT_THRESHOLD_MS = 60_000;
+const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000;
+
+/**
+ * Acquire a named lease for CDP coordination.
+ *
+ * @param {object} opts
+ * @param {string} [opts.lockDir] directory for lock files (default ~/.job-hunter/locks)
+ * @param {string} opts.leaseName unique lease identifier, e.g. "linkedin-search:9225"
+ * @param {string} [opts.runId] optional run identifier for diagnostics
+ * @param {number} [opts.heartbeatThresholdMs] max heartbeat age before lease considered stale (default 60000)
+ * @returns {object} lease handle { leaseName, leasePath, destroy }
+ */
+export function acquireLease({
+ lockDir,
+ leaseName,
+ runId = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
+ heartbeatThresholdMs = DEFAULT_HEARTBEAT_THRESHOLD_MS,
+} = {}) {
+ if (!leaseName) throw new Error('leaseName is required');
+
+ const dir = lockDir || path.join(homedir(), '.job-hunter', 'locks');
+ ensureDir(dir);
+
+ const safeName = leaseName.replace(/[^a-zA-Z0-9:_-]/g, '_');
+ const leasePath = path.join(dir, `${safeName}.lease`);
+ const lockPath = path.join(dir, `${safeName}.lease.lock`);
+
+ function readLease() {
+ return safeReadJson(leasePath);
+ }
+
+ function writeLease(data, ignoreStale = false) {
+ // Only write if we still own the lease (or if we're forcing)
+ if (!ignoreStale) {
+ const current = readLease();
+ if (!current || current.pid !== process.pid) return false;
+ }
+ return safeWriteJson(leasePath, data);
+ }
+
+ function isStale(data) {
+ if (!data) return true;
+ // Dead PID → stale
+ if (!pidAlive(data.pid)) return true;
+ // Heartbeat too old → stale
+ const heartbeat = data.heartbeat ? new Date(data.heartbeat).getTime() : 0;
+ if (Date.now() - heartbeat > heartbeatThresholdMs) return true;
+ return false;
+ }
+
+ // ── Acquire (try-create, then take-over if stale) ──────────────────
+ let data = readLease();
+ if (data && !isStale(data)) {
+ throw new Error(
+ `Lease "${leaseName}" already held by PID ${data.pid} (run ${data.runId || '?'}) since ${data.acquiredAt}`
+ );
+ }
+
+ if (data && isStale(data)) {
+ // Take over stale lease
+ console.warn(` [cdp-lease] Taking over stale lease "${leaseName}" (PID ${data.pid} ${pidAlive(data.pid) ? 'alive but heartbeat stale' : 'dead'})`);
+ safeUnlink(leasePath);
+ }
+
+ const now = new Date().toISOString();
+ const leaseData = {
+ leaseName,
+ pid: process.pid,
+ runId,
+ acquiredAt: now,
+ heartbeat: now,
+ tabs: [],
+ };
+
+ const created = atomicCreate(leasePath, JSON.stringify(leaseData, null, 2));
+ if (!created) {
+ // Raced — re-read and check
+ data = readLease();
+ if (data && !isStale(data)) {
+ throw new Error(
+ `Lease "${leaseName}" already held by PID ${data.pid} (run ${data.runId || '?'}) — lost race`
+ );
+ }
+ // Stale after all — force it
+ safeUnlink(leasePath);
+ const created2 = atomicCreate(leasePath, JSON.stringify(leaseData, null, 2));
+ if (!created2) {
+ throw new Error(`Could not acquire lease "${leaseName}" — atomic create failed after cleanup`);
+ }
+ }
+
+ console.log(` [cdp-lease] Acquired lease "${leaseName}" (run ${runId})`);
+
+ // ── Heartbeat starter ──────────────────────────────────────────────
+ let heartbeatTimer = null;
+
+ function startHeartbeat(intervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS) {
+ if (heartbeatTimer) return;
+ heartbeatTimer = setInterval(() => {
+ try {
+ const current = readLease();
+ if (!current || current.pid !== process.pid) return; // lost lease
+ current.heartbeat = new Date().toISOString();
+ writeLease(current, true);
+ } catch {
+ // heartbeat failure is non-fatal
+ }
+ }, intervalMs);
+ heartbeatTimer.unref();
+ }
+
+ startHeartbeat();
+
+ // ── Handle ─────────────────────────────────────────────────────────
+ const handle = {
+ leaseName,
+ leasePath,
+ /** Refresh heartbeat immediately. */
+ refreshHeartbeat() {
+ const current = readLease();
+ if (!current || current.pid !== process.pid) return false;
+ current.heartbeat = new Date().toISOString();
+ return writeLease(current, true);
+ },
+ /** Register a CDP target (tab) as owned by this lease. */
+ registerTab(targetId) {
+ if (!targetId) return;
+ try {
+ const current = readLease();
+ if (!current || current.pid !== process.pid) return;
+ if (!Array.isArray(current.tabs)) current.tabs = [];
+ if (!current.tabs.includes(targetId)) {
+ current.tabs.push(targetId);
+ writeLease(current, true);
+ }
+ } catch {
+ // best-effort
+ }
+ },
+ /** Get all tabs registered under this lease. */
+ getTabs() {
+ const current = readLease();
+ if (!current || !Array.isArray(current.tabs)) return [];
+ return [...current.tabs];
+ },
+ /** Release the lease and stop heartbeat. */
+ release() {
+ if (heartbeatTimer) {
+ clearInterval(heartbeatTimer);
+ heartbeatTimer = null;
+ }
+ try {
+ const current = readLease();
+ if (current && current.pid === process.pid) {
+ safeUnlink(leasePath);
+ console.log(` [cdp-lease] Released lease "${leaseName}"`);
+ }
+ // Also clean up mutex lockfile if we left it
+ safeUnlink(lockPath);
+ } catch {
+ // best-effort
+ }
+ },
+ };
+
+ return handle;
+}
+
+/**
+ * Non-throwing wrapper: returns the handle or null on failure.
+ */
+export function tryAcquireLease(opts) {
+ try {
+ return acquireLease(opts);
+ } catch (err) {
+ console.warn(` [cdp-lease] Could not acquire lease "${opts?.leaseName}": ${err.message}`);
+ return null;
+ }
+}
+
+// ── Shared cross-process request budget ──────────────────────────────
+
+/**
+ * Create a shared rate budget manager keyed by budgetName.
+ *
+ * @param {object} opts
+ * @param {string} [opts.lockDir]
+ * @param {string} opts.budgetName e.g. "linkedin.com:9225"
+ * @param {number} [opts.windowMs] rolling window in ms (default 60000)
+ * @param {number} [opts.maxRequests] max requests in window (default 12)
+ * @returns {{ waitForSlot: () => Promise, noteRequest: () => void, destroy: () => void }}
+ */
+export function createSharedBudget({
+ lockDir,
+ budgetName,
+ windowMs = 60_000,
+ maxRequests = 12,
+} = {}) {
+ if (!budgetName) throw new Error('budgetName is required');
+
+ const dir = lockDir || path.join(homedir(), '.job-hunter', 'locks');
+ ensureDir(dir);
+
+ const safeName = budgetName.replace(/[^a-zA-Z0-9:_-]/g, '_');
+ const budgetPath = path.join(dir, `budget-${safeName}.json`);
+ const lockPath = path.join(dir, `budget-${safeName}.lock`);
+
+ function readBudget() {
+ try {
+ if (!existsSync(budgetPath)) return { windowMs, maxRequests, requests: [] };
+ const data = JSON.parse(readFileSync(budgetPath, 'utf8'));
+ return {
+ windowMs: data.windowMs || windowMs,
+ maxRequests: data.maxRequests || maxRequests,
+ requests: Array.isArray(data.requests) ? data.requests : [],
+ };
+ } catch {
+ // Corrupt file → fail open
+ return { windowMs, maxRequests, requests: [] };
+ }
+ }
+
+ function writeBudget(budget) {
+ try {
+ writeFileSync(budgetPath, JSON.stringify(budget, null, 2), 'utf8');
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Prune timestamps outside the window. Returns the pruned array.
+ */
+ function pruneRequests(requests) {
+ const cutoff = Date.now() - windowMs;
+ return requests.filter((ts) => ts > cutoff);
+ }
+
+ const handle = {
+ /**
+ * Record a request in the shared budget. Best-effort; never throws.
+ */
+ noteRequest() {
+ try {
+ let budget = readBudget();
+ budget.requests = pruneRequests(budget.requests);
+ budget.requests.push(Date.now());
+ // Keep from growing unbounded (windowMs worth at most)
+ budget.requests = budget.requests.slice(-maxRequests * 3);
+ writeBudget(budget);
+ } catch {
+ // fail open
+ }
+ },
+
+ /**
+ * Wait until a request slot is available under the shared budget.
+ * Returns once a slot is acquired (our timestamp is recorded).
+ * On persistent budget file errors, fails open immediately.
+ */
+ async waitForSlot() {
+ while (true) {
+ let outcome;
+ try {
+ // Read-modify-write under the advisory lockfile so concurrent
+ // processes don't both claim the last slot. Small races after a
+ // mutex timeout are accepted (fail open).
+ outcome = await withMutex(lockPath, async () => {
+ const budget = readBudget();
+ budget.requests = pruneRequests(budget.requests);
+ if (budget.requests.length < maxRequests) {
+ budget.requests.push(Date.now());
+ budget.requests = budget.requests.slice(-maxRequests * 3);
+ const ok = writeBudget(budget);
+ return { acquired: true, writeOk: ok };
+ }
+ return { acquired: false, oldest: budget.requests[0], used: budget.requests.length };
+ });
+ } catch {
+ // Mutex timeout or I/O error — fail open to per-process limiting
+ console.warn(' [cdp-lease] Budget lock/IO error — proceeding without shared rate limit');
+ return;
+ }
+
+ if (outcome.acquired) {
+ if (!outcome.writeOk) {
+ console.warn(' [cdp-lease] Budget file write error — proceeding without shared rate limit');
+ }
+ return;
+ }
+
+ // Budget exhausted — compute wait
+ const waitMs = outcome.oldest + windowMs - Date.now() + 100;
+ if (waitMs <= 0) {
+ // Window already expired for oldest entry — retry prune+write
+ continue;
+ }
+
+ console.log(
+ ` [cdp-lease:budget] ${outcome.used}/${maxRequests} slots used, pausing ${(waitMs / 1000).toFixed(1)}s`
+ );
+ // Sleep for the full wait time so we don't spin. Maximum one log line per slot wait.
+ await sleep(waitMs);
+ }
+ },
+
+ /**
+ * Detach from the budget. Leaves the budget file in place — sibling
+ * processes may still be sharing it and it self-prunes by window —
+ * but clears any advisory lockfile we may have left behind.
+ */
+ destroy() {
+ try {
+ const holder = existsSync(lockPath) ? readFileSync(lockPath, 'utf8').trim() : null;
+ if (holder === String(process.pid)) safeUnlink(lockPath);
+ } catch {}
+ },
+ };
+
+ return handle;
+}
+
+/**
+ * Non-throwing wrapper for createSharedBudget.
+ */
+export function tryCreateSharedBudget(opts) {
+ try {
+ return createSharedBudget(opts);
+ } catch (err) {
+ console.warn(` [cdp-lease] Could not create shared budget "${opts?.budgetName}": ${err.message}`);
+ return null;
+ }
+}
diff --git a/skills/linkedin-job-search/scripts/cdp-preflight.mjs b/skills/linkedin-job-search/scripts/cdp-preflight.mjs
new file mode 100644
index 0000000..6009dd8
--- /dev/null
+++ b/skills/linkedin-job-search/scripts/cdp-preflight.mjs
@@ -0,0 +1,209 @@
+#!/usr/bin/env node
+import http from 'node:http';
+import { classifyLinkedInPage } from './linkedin-page-state.mjs';
+
+function parseArgs(argv) {
+ const opts = {
+ port: Number(process.env.BROWSER_CDP_PORT || process.env.LINKEDIN_CDP_PORT || 9225) || 9225,
+ json: false,
+ watchSeconds: 0,
+ keepAliveSeconds: Number(process.env.CDP_KEEPALIVE_SECONDS || 15) || 15,
+ probeUrls: [],
+ waitMs: 3000,
+ };
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ const next = () => {
+ if (i + 1 >= argv.length) throw new Error(`${arg} requires a value`);
+ return argv[++i];
+ };
+ if (arg === '--port' || arg === '--cdp-port') opts.port = Number(next()) || opts.port;
+ else if (arg === '--json') opts.json = true;
+ else if (arg === '--watch-seconds') opts.watchSeconds = Math.max(0, Number(next()) || 0);
+ else if (arg === '--keepalive-seconds') opts.keepAliveSeconds = Math.max(1, Number(next()) || 15);
+ else if (arg === '--probe-url') opts.probeUrls.push(next());
+ else if (arg === '--wait-ms') opts.waitMs = Math.max(500, Number(next()) || 3000);
+ else if (arg === '--help' || arg === '-h') {
+ console.log('Usage: cdp-preflight.mjs [--port 9225] [--json] [--probe-url URL] [--wait-ms 3000] [--watch-seconds N] [--keepalive-seconds N]');
+ process.exit(0);
+ } else throw new Error(`Unknown option: ${arg}`);
+ }
+ return opts;
+}
+
+function requestJson(port, path, method = 'GET') {
+ return new Promise((resolve, reject) => {
+ const req = http.request({ host: '127.0.0.1', port, path, method, timeout: 5000 }, (res) => {
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (chunk) => { body += chunk; });
+ res.on('end', () => {
+ try { resolve(JSON.parse(body)); }
+ catch (error) { reject(new Error(`Invalid CDP JSON at ${path}: ${error.message}`)); }
+ });
+ });
+ req.on('timeout', () => req.destroy(new Error(`CDP timeout at ${path}`)));
+ req.on('error', reject);
+ req.end();
+ });
+}
+
+function getJson(port, path) {
+ return requestJson(port, path, 'GET');
+}
+
+async function evaluateTarget(target) {
+ if (!target.webSocketDebuggerUrl) return null;
+ const ws = new WebSocket(target.webSocketDebuggerUrl);
+ const pending = new Map();
+ let seq = 0;
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('target WebSocket open timeout')), 5000);
+ ws.onopen = () => { clearTimeout(timer); resolve(); };
+ ws.onerror = () => { clearTimeout(timer); reject(new Error('target WebSocket error')); };
+ });
+ ws.onmessage = (event) => {
+ const message = JSON.parse(event.data);
+ const item = pending.get(message.id);
+ if (!item) return;
+ pending.delete(message.id);
+ message.error ? item.reject(new Error(message.error.message || JSON.stringify(message.error))) : item.resolve(message.result);
+ };
+ const send = (method, params = {}) => new Promise((resolve, reject) => {
+ const id = ++seq;
+ const timer = setTimeout(() => {
+ pending.delete(id);
+ reject(new Error(`${method} timeout`));
+ }, 5000);
+ pending.set(id, {
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
+ reject: (error) => { clearTimeout(timer); reject(error); },
+ });
+ ws.send(JSON.stringify({ id, method, params }));
+ });
+ try {
+ const result = await send('Runtime.evaluate', {
+ expression: 'JSON.stringify({title:document.title||"",url:location.href,text:(document.body?.innerText||"").slice(0,5000)})',
+ returnByValue: true,
+ });
+ return JSON.parse(result.result?.value || '{}');
+ } finally {
+ ws.close();
+ }
+}
+
+function classifyIndeedPage(page) {
+ const joined = `${page?.title || ''}\n${page?.text || ''}`;
+ if (/verify you are human|unusual traffic|security check|additional verification|required verification/i.test(joined)) {
+ return { state: 'blocked', reason: 'Indeed verification page detected' };
+ }
+ if (/indeed\.com/i.test(page?.url || '') && page?.text) return { state: 'healthy', reason: null };
+ return { state: 'unknown', reason: 'No readable Indeed page body' };
+}
+
+async function browserKeepAlive(wsUrl, durationMs, intervalMs) {
+ if (!durationMs) return { sent: 0, failed: 0 };
+ const ws = new WebSocket(wsUrl);
+ let seq = 0;
+ let sent = 0;
+ let failed = 0;
+ const pending = new Map();
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('browser WebSocket open timeout')), 5000);
+ ws.onopen = () => { clearTimeout(timer); resolve(); };
+ ws.onerror = () => { clearTimeout(timer); reject(new Error('browser WebSocket error')); };
+ });
+ ws.onmessage = (event) => {
+ const message = JSON.parse(event.data);
+ const item = pending.get(message.id);
+ if (!item) return;
+ pending.delete(message.id);
+ message.error ? item.reject(new Error(message.error.message || JSON.stringify(message.error))) : item.resolve(message.result);
+ };
+ const ping = () => new Promise((resolve, reject) => {
+ const id = ++seq;
+ const timer = setTimeout(() => {
+ pending.delete(id);
+ reject(new Error('heartbeat timeout'));
+ }, 5000);
+ pending.set(id, {
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
+ reject: (error) => { clearTimeout(timer); reject(error); },
+ });
+ ws.send(JSON.stringify({ id, method: 'Browser.getVersion', params: {} }));
+ });
+ const deadline = Date.now() + durationMs;
+ try {
+ while (Date.now() < deadline) {
+ try { await ping(); sent++; }
+ catch { failed++; }
+ const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
+ if (wait) await new Promise((resolve) => setTimeout(resolve, wait));
+ }
+ } finally {
+ ws.close();
+ }
+ return { sent, failed };
+}
+
+async function main() {
+ const opts = parseArgs(process.argv.slice(2));
+ const version = await getJson(opts.port, '/json/version');
+ const targets = await getJson(opts.port, '/json/list');
+ const relevant = targets.filter((target) => target.type === 'page' && /linkedin\.com|indeed\.com/i.test(target.url || ''));
+ const pages = [];
+ for (const target of relevant) {
+ try {
+ const page = await evaluateTarget(target);
+ const site = /linkedin\.com/i.test(page?.url || target.url || '') ? 'linkedin' : 'indeed';
+ const check = site === 'linkedin' ? classifyLinkedInPage(page) : classifyIndeedPage(page);
+ pages.push({ site, state: check.state, reason: check.reason, title: page?.title || target.title, url: page?.url || target.url });
+ } catch (error) {
+ pages.push({ site: /linkedin\.com/i.test(target.url || '') ? 'linkedin' : 'indeed', state: 'error', reason: error.message, title: target.title, url: target.url });
+ }
+ }
+ for (const url of opts.probeUrls) {
+ let target;
+ try {
+ target = await requestJson(opts.port, `/json/new?${encodeURIComponent(url)}`, 'PUT');
+ await new Promise((resolve) => setTimeout(resolve, opts.waitMs));
+ const page = await evaluateTarget(target);
+ const site = /linkedin\.com/i.test(page?.url || url) ? 'linkedin' : /indeed\.com/i.test(page?.url || url) ? 'indeed' : 'other';
+ const check = site === 'linkedin'
+ ? classifyLinkedInPage(page)
+ : site === 'indeed'
+ ? classifyIndeedPage(page)
+ : { state: page?.text ? 'healthy' : 'unknown', reason: page?.text ? null : 'No readable page body' };
+ pages.push({ site, state: check.state, reason: check.reason, title: page?.title || target.title, url: page?.url || url, probe: true });
+ } catch (error) {
+ pages.push({ site: /linkedin\.com/i.test(url) ? 'linkedin' : /indeed\.com/i.test(url) ? 'indeed' : 'other', state: 'error', reason: error.message, title: '', url, probe: true });
+ } finally {
+ if (target?.id) await requestJson(opts.port, `/json/close/${target.id}`, 'PUT').catch(() => {});
+ }
+ }
+ const heartbeat = await browserKeepAlive(
+ version.webSocketDebuggerUrl,
+ opts.watchSeconds * 1000,
+ opts.keepAliveSeconds * 1000,
+ );
+ const result = {
+ ok: Boolean(version.webSocketDebuggerUrl) && !pages.some((page) => ['blocked', 'captcha', 'login_required', 'error'].includes(page.state)),
+ port: opts.port,
+ browser: version.Browser || version.browser || null,
+ pageCount: targets.filter((target) => target.type === 'page').length,
+ pages,
+ heartbeat,
+ };
+ if (opts.json) console.log(JSON.stringify(result));
+ else {
+ console.log(`CDP ${result.port}: ${result.browser || 'reachable'}; ${result.pageCount} page target(s)`);
+ for (const page of pages) console.log(`${page.site.toUpperCase()} ${page.state}: ${page.title} — ${page.url}${page.reason ? ` — ${page.reason}` : ''}`);
+ if (opts.watchSeconds) console.log(`Heartbeat: ${heartbeat.sent} sent, ${heartbeat.failed} failed`);
+ }
+ process.exitCode = result.ok ? 0 : 3;
+}
+
+main().catch((error) => {
+ console.error(JSON.stringify({ ok: false, error: error.message }));
+ process.exitCode = 2;
+});
diff --git a/skills/linkedin-job-search/scripts/direct-jd-fetch.mjs b/skills/linkedin-job-search/scripts/direct-jd-fetch.mjs
new file mode 100644
index 0000000..ab59572
--- /dev/null
+++ b/skills/linkedin-job-search/scripts/direct-jd-fetch.mjs
@@ -0,0 +1,176 @@
+import { Database, WebSocket } from '../../job-hunter/scripts/workspace-dependencies.mjs';
+// Direct CDP JD backfill for jobs the batch script couldn't extract.
+// Opens each LinkedIn job URL, extracts JD via DOM, saves to DB.
+
+import http from 'http';
+
+import { writeFileSync } from 'node:fs';
+import { startCdpKeepAlive } from './cdp-keepalive.mjs';
+
+const JH = process.env.JOBHUNTER_HOME || `${process.env.HOME}/.job-hunter`;
+const DB = process.env.JOBHUNTER_DB || `${JH}/jobhunter.sqlite`;
+const CDP_PORT = 9225;
+const JOBS = process.argv.slice(2);
+if (JOBS.length === 0) { console.error('Usage: node direct-jd-fetch.mjs [job_id2] ...'); process.exit(1); }
+
+const db = new Database(DB);
+
+function httpJson(method, path) {
+ return new Promise((resolve, reject) => {
+ const req = http.request({ method, host: '127.0.0.1', port: CDP_PORT, path }, res => {
+ let d = ''; res.on('data', c => d += c); res.on('end', () => {
+ try { resolve(JSON.parse(d)); } catch { reject(new Error(d.slice(0,200))); }
+ });
+ });
+ req.on('error', reject); req.end();
+ });
+}
+
+class Cdp {
+ constructor(wsUrl) { this.wsUrl = wsUrl; this.nid = 1; this.pending = new Map(); }
+ async connect() {
+ this.ws = new WebSocket(this.wsUrl);
+ await new Promise((resolve, reject) => {
+ const t = setTimeout(() => reject(new Error('WS open timeout')), 10000);
+ this.ws.on('open', () => { clearTimeout(t); resolve(); });
+ this.ws.on('error', e => { clearTimeout(t); reject(e); });
+ });
+ this.ws.on('message', raw => {
+ try {
+ const m = JSON.parse(raw);
+ const p = this.pending.get(m.id);
+ if (!p) return;
+ clearTimeout(p.t);
+ this.pending.delete(m.id);
+ if (m.error) p.reject(new Error(m.error.message));
+ else p.resolve(m.result);
+ } catch {}
+ });
+ }
+ send(method, params = {}, timeout = 20000) {
+ const id = this.nid++;
+ return new Promise((resolve, reject) => {
+ const t = setTimeout(() => { this.pending.delete(id); reject(new Error(`Timeout: ${method}`)); }, timeout);
+ this.pending.set(id, { resolve, reject, t });
+ this.ws.send(JSON.stringify({ id, method, params }));
+ });
+ }
+ async eval(expr, timeout = 20000) {
+ const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: false }, timeout);
+ if (r?.result?.value !== undefined) return r.result.value;
+ if (r?.result?.result?.value !== undefined) return r.result.result.value;
+ if (r?.exceptionDetails) throw new Error('JS: ' + JSON.stringify(r.exceptionDetails).slice(0,200));
+ return r?.result;
+ }
+ close() { try { this.ws.close(); } catch {} }
+}
+
+const delay = ms => new Promise(r => setTimeout(r, ms));
+
+async function fetchOne(jobId) {
+ // Look up URL from DB
+ const row = db.prepare('SELECT url, title, company FROM jobs WHERE source = ? AND job_id = ?').get('linkedin', jobId);
+ if (!row) { console.log(` ${jobId}: not in DB`); return { jobId, status: 'not_in_db' }; }
+ const url = row.url;
+ // Strip /uk prefix to get the canonical /jobs/view/ form
+ let jobUrl = url;
+ const m = url.match(/linkedin\.com\/jobs\/view\/\d+/);
+ if (m) jobUrl = 'https://www.' + m[0];
+
+ console.log(` ${jobId} (${row.title?.slice(0,30)}): navigating to ${jobUrl.slice(0,80)}`);
+ const tab = await httpJson('PUT', `/json/new?url=${encodeURIComponent(jobUrl)}`);
+ const tabId = tab.id;
+ const cdp = new Cdp(tab.webSocketDebuggerUrl);
+ let keepAlive;
+ let result = { jobId, status: 'unknown' };
+ try {
+ await cdp.connect();
+ keepAlive = startCdpKeepAlive(cdp, {
+ intervalMs: Math.max(0, Number(process.env.CDP_KEEPALIVE_SECONDS ?? 15) || 0) * 1000,
+ label: 'LinkedIn direct-JD CDP',
+ });
+ await cdp.send('Page.enable');
+ await cdp.send('Runtime.enable');
+ await cdp.send('Page.navigate', { url: jobUrl });
+ await delay(7000);
+
+ // Extract JD text — try multiple selectors
+ const jd = await cdp.eval(`(function() {
+ // LinkedIn JD containers (multiple variants across redesigns)
+ const selectors = [
+ '.jobs-description__content .jobs-box__html',
+ '.jobs-description__content',
+ '.jobs-description .jobs-box__html',
+ '.description__text--rich',
+ '.description__text',
+ 'article.jobs-description',
+ '[data-testid="expandable-text-box"]',
+ 'div[class*="jobs-description"]',
+ 'div[class*="show-more-less-html"]',
+ 'div[class*="job-details"]',
+ '#job-details',
+ ];
+ let best = '';
+ let bestLen = 0;
+ for (const sel of selectors) {
+ const els = document.querySelectorAll(sel);
+ for (const el of els) {
+ const txt = (el.innerText || el.textContent || '').trim();
+ if (txt.length > bestLen && txt.length > 200) { best = txt; bestLen = txt.length; }
+ }
+ }
+ // Fallback: largest text block on page
+ if (bestLen < 200) {
+ const all = Array.from(document.querySelectorAll('div, section, article'));
+ for (const el of all) {
+ const txt = (el.innerText || '').trim();
+ if (txt.length > bestLen && txt.length > 500 && txt.length < 50000) { best = txt; bestLen = txt.length; }
+ }
+ }
+ return { jd: best, jd_len: best.length, title: document.title, url: location.href };
+ })()`);
+
+ if (jd.jd && jd.jd.length > 200) {
+ // Save to DB
+ db.prepare('UPDATE jobs SET description_text = ?, description_raw = ?, language_filter_reason = COALESCE(language_filter_reason, ?) WHERE source = ? AND job_id = ?')
+ .run(jd.jd, jd.jd, 'PASS: extracted via direct CDP', 'linkedin', jobId);
+ console.log(` ✓ saved ${jd.jd_len} chars`);
+ result = { jobId, status: 'ok', jd_len: jd.jd_len };
+ } else {
+ console.log(` ✗ extracted only ${jd.jd_len} chars (title: ${jd.title?.slice(0,60)})`);
+ // Save screenshot
+ try {
+ const ss = await cdp.send('Page.captureScreenshot', { format: 'png' });
+ writeFileSync(`/tmp/jd-FAIL-${jobId}.png`, Buffer.from(ss.data, 'base64'));
+ } catch {}
+ result = { jobId, status: 'fail', jd_len: jd.jd_len, page_title: jd.title, page_url: jd.url };
+ }
+ } catch (e) {
+ console.log(` ✗ ERR: ${e.message}`);
+ result = { jobId, status: 'error', err: e.message };
+ } finally {
+ keepAlive?.stop();
+ cdp.close();
+ // Close tab
+ try {
+ await httpJson('PUT', `/json/close/${tabId}`);
+ } catch {}
+ }
+ return result;
+}
+
+async function main() {
+ const results = [];
+ for (const jid of JOBS) {
+ const r = await fetchOne(jid);
+ results.push(r);
+ await delay(2000); // human-like pause
+ }
+ console.log('\n=== Summary ===');
+ for (const r of results) {
+ console.log(` ${r.jobId}: ${r.status}` + (r.jd_len ? ` (${r.jd_len} chars)` : '') + (r.err ? ` — ${r.err}` : ''));
+ }
+ process.exit(0);
+}
+
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });
diff --git a/skills/linkedin-job-search/scripts/linkedin-extractor.mjs b/skills/linkedin-job-search/scripts/linkedin-extractor.mjs
new file mode 100644
index 0000000..f7ebc4a
--- /dev/null
+++ b/skills/linkedin-job-search/scripts/linkedin-extractor.mjs
@@ -0,0 +1,313 @@
+/**
+ * LinkedIn Job Extractor
+ *
+ * Extracts fields from LinkedIn job HTML using regex patterns.
+ * Used by both the test harness and the skill instructions.
+ *
+ * Patterns are derived from common LinkedIn DOM structures.
+ * When LinkedIn changes their DOM, update these patterns.
+ */
+
+/**
+ * Extract the job title from HTML.
+ * @param {string} html
+ * @returns {string|null}
+ */
+export function extractTitle(html) {
+ const patterns = [
+ /class="top-card-layout__title"[^>]*>([^<]+),
+ /class="job-details-jobs-v2__main-job-title">([^<]+),
+ /class="job-card-list__title"[^>]*>([^<]+),
+ /class="job-title"[^>]*>([^<]+),
+ ];
+ for (const p of patterns) {
+ const m = html.match(p);
+ if (m) return m[1].trim();
+ }
+ return null;
+}
+
+/**
+ * Extract company name.
+ * @param {string} html
+ * @returns {string|null}
+ */
+export function extractCompany(html) {
+ const patterns = [
+ /class="topcard__org-name-link"[^>]*>([^<]+),
+ /class="job-details-jobs-v2__company-name"[^>]*>([^<]+),
+ /class="job-card-list__company-name"[^>]*>([^<]+),
+ /class="company-name"[^>]*>([^<]+),
+ ];
+ for (const p of patterns) {
+ const m = html.match(p);
+ if (m) return m[1].trim();
+ }
+ return null;
+}
+
+/**
+ * Extract location.
+ * @param {string} html
+ * @returns {string|null}
+ */
+export function extractLocation(html) {
+ const patterns = [
+ /class="topcard__flavor--bullet"[^>]*>([^<]+),
+ /class="job-details-jobs-v2__location"[^>]*>([^<]+),
+ /class="job-card-list__location"[^>]*>([^<]+),
+ /class="job-location"[^>]*>([^<]+),
+ ];
+ for (const p of patterns) {
+ const m = html.match(p);
+ if (m) return m[1].trim();
+ }
+ return null;
+}
+
+/**
+ * Extract applicant count from HTML.
+ * @param {string} html
+ * @returns {number|null} null if no applicant info found
+ */
+export function extractApplicants(html) {
+ const patterns = [
+ /class="num-applicants__figure"[^>]*>(\d+)[^<]*,
+ /class="job-card-list__applicant-count"[^>]*>(\d+)\s*applicants?/,
+ /class="job-details-jobs-v2__applicant-count"[^>]*>(\d+)\s*applicants?/,
+ /over\s+(\d+)\s*applicants?/i,
+ /(\d+)\+?\s*applicants?\s*(?:applied|have applied)?/i,
+ ];
+ for (const p of patterns) {
+ const m = html.match(p);
+ if (m) return parseInt(m[1], 10);
+ }
+ return null;
+}
+
+/**
+ * Extract the full description text from HTML.
+ * @param {string} html
+ * @returns {string}
+ */
+export function extractDescription(html) {
+ const patterns = [
+ /class="description__text"[^>]*>([\s\S]*?)<\/div>/,
+ /class="show-more-less__description"[^>]*>([\s\S]*?)<\/div>/,
+ /class="job-card-list__description"[^>]*>([\s\S]*?)<\/div>/,
+ ];
+ for (const p of patterns) {
+ const m = html.match(p);
+ if (m) {
+ // Strip HTML tags from description
+ return m[1].replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+ }
+ }
+ return '';
+}
+
+/**
+ * Extract application links from HTML.
+ * @param {string} html
+ * @returns {string[]}
+ */
+export function extractApplicationLinks(html) {
+ const links = [];
+ const patterns = [
+ /class="apply-link"[^>]*href="([^"]+)"/,
+ /class="apply-link external"[^>]*href="([^"]+)"/,
+ /class="apply-button"[^>]*href="([^"]+)"/,
+ ];
+ for (const p of patterns) {
+ const m = html.match(p);
+ if (m && m[1] && m[1] !== '#') links.push(m[1]);
+ }
+ return links;
+}
+
+/**
+ * Extract recruiter/hirer name.
+ * @param {string} html
+ * @returns {string|null}
+ */
+export function extractRecruiter(html) {
+ const patterns = [
+ /class="hirer-name"[^>]*>([^<]+),
+ /class="recruiter-name"[^>]*>([^<]+),
+ /class="hirer-info"[^>]*>[\s\S]*?class="hirer-name"[^>]*>([^<]+),
+ ];
+ for (const p of patterns) {
+ const m = html.match(p);
+ if (m) return m[1].trim();
+ }
+ return null;
+}
+
+/**
+ * Extract job posting date from HTML.
+ * @param {string} html
+ * @returns {string|null}
+ */
+export function extractJobPostingDate(html) {
+ const patterns = [
+ /class="job-posted-date"[^>]*>([^<]+),
+ /class="posted-date"[^>]*>([^<]+),
+ /class="posted-date"[^>]*datetime="([^"]+)"/,
+ /class="job-posted-date"[^>]*datetime="([^"]+)"/,
+ /class="job-details-jobs-v2__posted-date"[^>]*>([^<]+),
+ ];
+ for (const p of patterns) {
+ const m = html.match(p);
+ if (m) return m[1].trim();
+ }
+ return null;
+}
+
+/**
+ * Extract recruiter email from HTML.
+ * @param {string} html
+ * @returns {string|null}
+ */
+export function extractRecruiterEmail(html) {
+ const patterns = [
+ /class="hirer-email"[^>]*>([^<]+),
+ /class="recruiter-email"[^>]*>([^<]+),
+ /class="hirer-info"[^>]*>[\s\S]*?([\w.+-]+@[\w.-]+\.[a-zA-Z]{2,})[\s\S]*?,
+ /mailto:([^")]+)/,
+ ];
+ for (const p of patterns) {
+ const m = html.match(p);
+ if (m) return m[1].trim();
+ }
+ return null;
+}
+
+/**
+ * Extract recruiter profile link from HTML.
+ * @param {string} html
+ * @returns {string|null}
+ */
+export function extractRecruiterProfileLink(html) {
+ const patterns = [
+ /class="hirer-name"[^>]*href="([^"]+)"/,
+ /class="recruiter-name"[^>]*href="([^"]+)"/,
+ /class="hirer-info"[^>]*>[\s\S]*?class="hirer-name"[^>]*href="([^"]+)"/,
+ ];
+ for (const p of patterns) {
+ const m = html.match(p);
+ if (m) return m[1].trim();
+ }
+ return null;
+}
+
+/**
+ * Parse language requirements from a description string.
+ *
+ * Returns an object with:
+ * - required: string[] of languages that are required
+ * - niceToHave: string[] of languages that are nice-to-have
+ *
+ * @param {string} description
+ * @returns {{ required: string[], niceToHave: string[] }}
+ */
+export function parseLanguageRequirements(description) {
+ const result = { required: [], niceToHave: [] };
+ if (!description) return result;
+
+ // Known language keywords to detect
+ const languageKeywords = [
+ 'English', 'German', 'French', 'Spanish', 'Italian',
+ 'Portuguese', 'Dutch', 'Japanese', 'Chinese', 'Korean',
+ 'Arabic', 'Example Company 094n', 'Polish', 'Swedish', 'Danish',
+ 'Norwegian', 'Finnish', 'Turkish', 'Hindi', 'Bengali',
+ ];
+
+ // Build regex that matches any of these languages
+ const langPattern = languageKeywords.join('|');
+
+ // Required patterns: "Required: Fluent in X", "Must speak X", "X is required", "Requirements: X"
+ const requiredRegex = new RegExp(
+ `(?:Required|Requirement|Must|Fluent|Proficient|Mandatory|Essential)`
+ + `[^.]*?(?:in|speak|speaking|language|fluent|proficient)`
+ + `[^.]*?\\b(${langPattern})\\b`,
+ 'gi'
+ );
+ let m;
+ while ((m = requiredRegex.exec(description)) !== null) {
+ const lang = capitalizeLang(m[1]);
+ if (!result.required.includes(lang)) result.required.push(lang);
+ }
+
+ // Also catch simpler patterns like "Required: English" or "Requirements: English"
+ const simpleRequired = new RegExp(
+ `(?:Required|Requirements):\\s*(${langPattern})`,
+ 'gi'
+ );
+ while ((m = simpleRequired.exec(description)) !== null) {
+ const lang = capitalizeLang(m[1]);
+ if (!result.required.includes(lang)) result.required.push(lang);
+ }
+
+ // Nice-to-have patterns: "Nice to have: X", "X is a plus", "X is nice to have"
+ const niceRegex = new RegExp(
+ `(?:Nice|Plus|Bonus|Preferred|Optional|Good to have)`
+ + `[^.]*?(?:to have|skill|language|speak|speaking)`
+ + `[^.]*?\\b(${langPattern})\\b`,
+ 'gi'
+ );
+ while ((m = niceRegex.exec(description)) !== null) {
+ const lang = capitalizeLang(m[1]);
+ if (!result.niceToHave.includes(lang)) result.niceToHave.push(lang);
+ }
+
+ // Also catch "X is a plus", "X is nice to have"
+ const simpleNice = new RegExp(
+ `(${langPattern})[^.]*?(?:is a plus|is nice to have|nice to have)`,
+ 'gi'
+ );
+ while ((m = simpleNice.exec(description)) !== null) {
+ const lang = capitalizeLang(m[1]);
+ if (!result.niceToHave.includes(lang)) result.niceToHave.push(lang);
+ }
+
+ return result;
+}
+
+/**
+ * Determine if a job passes the language filter.
+ *
+ * A job passes if:
+ * - No required languages are specified (pass by default)
+ * - All required languages match the user's language
+ * - A required language is listed as "nice to have" (not strictly required)
+ *
+ * @param {{ required: string[], niceToHave: string[] }} requirements
+ * @param {string} userLanguage - e.g. "English", "German"
+ * @returns {boolean} true = include job, false = skip
+ */
+export function passesLanguageFilter(requirements, userLanguage) {
+ if (!requirements || !userLanguage) return true;
+
+ // Normalize user language
+ const userLang = capitalizeLang(userLanguage);
+
+ // If no required languages, job passes
+ if (requirements.required.length === 0) return true;
+
+ // Check if any required language does NOT match user's language
+ const allMatch = requirements.required.every(
+ (req) => req.toLowerCase() === userLang.toLowerCase()
+ );
+
+ return allMatch;
+}
+
+/**
+ * Capitalize a language name properly.
+ * @param {string} lang
+ * @returns {string}
+ */
+function capitalizeLang(lang) {
+ if (!lang) return '';
+ return lang.charAt(0).toUpperCase() + lang.slice(1).toLowerCase();
+}
diff --git a/skills/linkedin-job-search/scripts/linkedin-page-state.mjs b/skills/linkedin-job-search/scripts/linkedin-page-state.mjs
new file mode 100644
index 0000000..e9058d1
--- /dev/null
+++ b/skills/linkedin-job-search/scripts/linkedin-page-state.mjs
@@ -0,0 +1,230 @@
+const PASSIVE_HTML_RE = /<(?:script|style|iframe|noscript)\b[^>]*>[\s\S]*?<\/(?:script|style|iframe|noscript)>|<(?:iframe|meta|link)\b[^>]*\/?\s*>/gi;
+
+const BLOCK_SIGNATURES = [
+ // ── Captcha / active challenge (checked first — these take precedence) ──
+ { re: /(?:captcha|recaptcha|hcaptcha).{0,120}(?:solve|verify|verification|challenge|continue)/i, state: 'active_challenge' },
+ { re: /(?:solve|verify|verification|challenge).{0,120}(?:captcha|recaptcha|hcaptcha)/i, state: 'active_challenge' },
+ { re: /please solve/i, state: 'active_challenge' },
+ // Adjacency required: LinkedIn's checkpoint markup uses "challengePlatform" /
+ // "challenge verification". A {0,30} gap matched ordinary job-description prose
+ // ("challenges into platform") and broke healthy pages.
+ { re: /challenge[-_\s]?platform\b/i, state: 'active_challenge' },
+ { re: /challenge[-_\s]?verification\b/i, state: 'active_challenge' },
+ // ── Rate limiting ──
+ { re: /too many requests/i, state: 'rate_limited' },
+ { re: /rate limit/i, state: 'rate_limited' },
+ { re: /temporarily restricted/i, state: 'rate_limited' },
+ // ── Other blocks (catch-all) ──
+ { re: /security (?:verification|check)/i, state: 'blocked' },
+ { re: /verify you.{0,40}(?:human|real person)/i, state: 'blocked' },
+ { re: /are you a (?:real person|human)/i, state: 'blocked' },
+ { re: /we.{0,30}detected.{0,30}(?:unusual|suspicious)/i, state: 'blocked' },
+ { re: /unusual activity/i, state: 'blocked' },
+ { re: /sign.?in to confirm you.{0,30}(?:real|not a bot)/i, state: 'blocked' },
+];
+
+/**
+ * Canonical page state values shared across the runner and its callers.
+ */
+export const PAGE_STATE = {
+ HEALTHY: 'healthy',
+ EMPTY: 'empty',
+ LOGIN_REQUIRED: 'login_required',
+ ACTIVE_CHALLENGE: 'active_challenge',
+ RATE_LIMITED: 'rate_limited',
+ BLOCKED: 'blocked',
+ TRANSIENT_ERROR: 'transient_error',
+};
+
+/**
+ * Terminal run-level statuses (page states + run-level only).
+ */
+export const RUN_STATUS = {
+ ...PAGE_STATE,
+ FAILED: 'failed',
+ CANCELLED: 'cancelled',
+};
+
+function visibleLikeText(input) {
+ const raw = String(input || '');
+ if (!/<(?:html|body|script|iframe|style)\b/i.test(raw)) return raw;
+ return raw
+ .replace(PASSIVE_HTML_RE, ' ')
+ .replace(//g, ' ')
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/ | /gi, ' ')
+ .replace(/&(?:amp|lt|gt|quot);/gi, ' ')
+ .replace(/\s+/g, ' ');
+}
+
+/**
+ * True when the payload carries rendered LinkedIn job-result data. Mirrors the
+ * result markers search-linkedin-jobs.mjs uses to accept a search page.
+ *
+ * @param {string} htmlOrText - raw HTML or visible text of the page
+ * @returns {boolean}
+ */
+export function hasJobResultMarkers(htmlOrText) {
+ const raw = String(htmlOrText || '');
+ if (!raw) return false;
+ return /"job_id":\d+|\/jobs\/view\/\d+|jobCardPrefetchQueries|totalResultSize|data-job-id=|job-card-container/i.test(raw);
+}
+
+/**
+ * True when the payload is a rendered job-detail page. Mirrors the JD markers
+ * search-linkedin-jobs.mjs waits for before accepting detail text.
+ *
+ * @param {string} htmlOrText - raw HTML or visible text of the page
+ * @returns {boolean}
+ */
+export function hasJobDetailMarkers(htmlOrText) {
+ const raw = String(htmlOrText || '');
+ if (!raw) return false;
+ return /Report this job|Seniority level|About the job|Employment type|Job function/i.test(raw);
+}
+
+/**
+ * Detect whether a LinkedIn HTML page is a block/warning/challenge.
+ * CRITICAL INVARIANT: passive invisible reCAPTCHA scripts/iframes present on
+ * HEALTHY pages must NOT be classified as a challenge.
+ *
+ * @param {string} htmlOrText - raw HTML or visible-text of the page
+ * @returns {{ blocked: boolean, isCaptcha: boolean, reason: string|null }}
+ * blocked = any block/warning page (true for all block types)
+ * isCaptcha = specifically a CAPTCHA challenge (otherwise generic block)
+ * reason = human-readable explanation
+ */
+export function detectLinkedInBlockPage(htmlOrText) {
+ const hay = visibleLikeText(htmlOrText);
+ for (const signature of BLOCK_SIGNATURES) {
+ if (signature.re.test(hay)) {
+ return {
+ blocked: true,
+ isCaptcha: signature.state === 'active_challenge',
+ reason: `matched visible page text: ${signature.re.source.slice(0, 70)}`,
+ };
+ }
+ }
+ return { blocked: false, isCaptcha: false, reason: null };
+}
+
+/**
+ * Classify a LinkedIn page into one of the canonical PAGE_STATE values.
+ * Classification is based on visible/accessible page text and title, never
+ * on raw
+AI Architect