Job Hunter project guard: protects .env, data/jobs.db and cv/cv_master.yaml, and runs ruff after every Python edit

A local AI agent that collects software, data and AI job postings (in Israel, plus remote jobs open to Israel), scores each one against your CV, and shows the results in a Streamlit dashboard where you track your applications.
Built with LangGraph, LangChain, Pydantic, SQLite and Streamlit. The LLM is optional: without an API key, everything still runs on a free heuristic score.
<img alt="Job Hunter dashboard: KPI cards, the scored jobs table and the run sidebar (Hebrew, RTL)" src="docs/images/dashboard-light.png">
JobMatch: a score, the reasoning, missing skills and a CV tip.status and notes.flowchart LR
CV[CV file] --> P[load_profile]
P --> C[collect<br/>parallel, fault-tolerant]
C --> N[dedupe]
N --> F[rule filter]
F --> PS[pre-score<br/>TF-IDF + skills]
PS -->|top N, LLM on| LS[LLM score]
PS -->|no LLM| DB
LS --> DB[(SQLite)]
DB --> D[Streamlit dashboard]
StateGraph with one conditional edge (LLM or no LLM). It is repeatable, cheap and easy to test.--agent). A ReAct-style loop in which the model writes its own searches with the tools search_jobs, read_posting and save_match, and stops once it has saved N good matches. The guardrails live in code, not in the prompt: step and search budgets, a job must be read before it can be saved, filtered jobs are never shown to the model, and searches must cover every role group.Requires Python 3.11+. Developed on Windows.
pip install -r requirements.txt # requirements-dev.txt adds pytest and the design tools
streamlit run dashboard.py and upload your CV (PDF, DOCX or TXT, in Hebrew or English) in the Profile tab, or put it in cv/ yourself. The newest file is used..env.example to .env and add your key (for example ANTHROPIC_API_KEY=...). Set the provider and model under llm: in config.yaml.python run_agent.py --no-llm for a free first run.A step-by-step guide in Hebrew, for anyone who just wants to use the tool, is in docs/USER_GUIDE.he.md.
python run_agent.py # full run: collect, filter, score, save
python run_agent.py --no-llm # heuristic scores only (free)
python run_agent.py --sources ats,remotive --limit 50 # partial run
python run_agent.py --dry-run # no DB writes; prints the top 15
python run_agent.py --rescore # re-score stored jobs against the current CV
python run_agent.py --check-companies # validate companies.yaml
python run_agent.py --agent --goal 5 # tool-using agent loop
python run_agent.py --user dana # run for a person added in the dashboard
python -m jobhunter.handoff --list # best jobs and their ids
python -m jobhunter.handoff <job_id> --launch # brief for Claude, open Claude Code on it
streamlit run dashboard.py # dashboard
pytest -q # tests (offline, recorded fixtures)
In a job's card, open "הכנת מועמדות עם Claude" and click "הכנת תיק למשרה". This writes data/tailored/<job>/brief.md. From there:
ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN from that window's environment: the dashboard has .env loaded, and when Claude Code finds an API key in its environment it asks to use it instead of your subscription (once approved, it bills the API)./apply <job_id>.Claude saves cv_tailored.md (plus a .docx copy), cover_letter.md, interview_prep.md and changes.md next to the brief, and the dashboard lists them for download. The brief forbids adding any skill, employer, date or number that is not in the CV.
Profile tab → "הוספת אדם נוסף": a name, a CV and, optionally, job titles. This creates users/<id>/ (gitignored) with that person's CV, their own jobs.db and run files, and a config.yaml that holds only what differs from the main one. That file turns on auto_from_cv: the search titles, the role filter and the experience limit come from their CV (jobhunter/personalize.py), because the main config.yaml is tuned for the owner's junior CS search. Without an LLM, the titles typed in the form are used. Pick who is searching at the top of the sidebar. Schedule a daily run for them with scripts\schedule_windows.ps1 -User <id>. The tool-using agent loop stays the owner's.
A full run takes a few minutes. Most of that time is JobSpy, which waits between queries so LinkedIn does not rate-limit it.
powershell -ExecutionPolicy Bypass -File scripts\schedule_windows.ps1 # every day at 08:30
powershell -ExecutionPolicy Bypass -File scripts\schedule_windows.ps1 -At 7:15am
powershell -ExecutionPolicy Bypass -File scripts\schedule_windows.ps1 -RunNow
powershell -ExecutionPolicy Bypass -File scripts\schedule_windows.ps1 -Remove
This registers a Task Scheduler task named JobHunterAgent. If the PC was off at the scheduled time, the run starts when it turns on. Logs go to data/logs/.
All settings live in config.yaml:
| Section | What it controls |
|---|---|
profile | Manual corrections to the skills and seniority extracted from the CV (profile.overrides) |
filters | Location, roles, seniority, max_required_years (0 = junior only; null = no limit) |
search | Queries and results_per_query |
sources | Which collectors run, and their options |
matching | llm_top_n (how many jobs go to the LLM), fetch_descriptions, feedback_examples (how many of your marked jobs the LLM sees) |
agent | Agent-loop goal, budgets, role groups and an optional separate model |
notify | Desktop notification for new jobs scoring at or above min_score |
llm | Provider and model (provider: none turns the LLM off) |
users/<id>/config.yaml | Per-person overrides on top of config.yaml (display_name, auto_from_cv, search.queries) |
To add companies, put their slugs in companies.yaml and run --check-companies. scripts/sync_companies.py pulls new boards from the Israeli Tech Map and VC job boards.
run_agent.py CLI entry point
dashboard.py Streamlit app
config.yaml settings
companies.yaml company career boards
jobhunter/
agent.py LangGraph pipeline
agent_loop/ tool-using agent loop
collectors/ one module per source; each returns list[Job] and never raises
filters.py location / role / seniority / age rules
dedupe.py same posting / same role on several boards
matcher.py pre-score + parallel LLM scoring
prompts.py every LLM prompt in one place (and the Claude brief)
cv.py CV parsing and profile extraction (cached by file hash)
db.py SQLite schema, upserts, run history
handoff.py one job -> brief for Claude Code / claude.ai, Markdown -> Word
users.py people added from the dashboard (users/<id>/)
personalize.py search settings derived from a CV (auto_from_cv)
runlock.py stop.py one run at a time, graceful stop
ui/ dashboard design system (tokens, CSS, components)
scripts/ board checks, company sync, Windows scheduling
tests/ pytest suite with recorded API responses and a fake LLM
docs/ architecture, implementation plan, Hebrew user guide
See docs/ARCHITECTURE.md for the data model, the scoring formula and the design decisions.
status or notes.401 invalid x-api-key: the key is wrong or was revoked. Create a new one.not scoped to a workspace: set llm.anthropic_workspace_id in config.yaml, or create the key inside a workspace.search.results_per_query.data/run.lock (users/<id>/data/run.lock for another person).claude from a terminal where ANTHROPIC_API_KEY is not set. /status inside Claude Code shows which login it uses.docs/IMPLEMENTATION_PLAN.md.hooks/register.ts 57 lines1import type { Register } from 'claude-code'
2
3import { checkBash, checkFile, isPython } from './rules'
4
5export const register: Register = on => {
6 let hasRuff: boolean | undefined // unknown until the first Python edit
7
8 // 1. File guard: .env, the jobs DB, the master CV
9 on('tool.call', { tool: ['Read', 'Edit', 'Write'] }, async ($, e, next) => {
10 const exists = await $.fs.exists(e.file_path).catch(() => true)
11 const verdict = checkFile(e.tool, e.file_path, exists)
12 if (verdict.isBlocked) {
13 return { deny: `${$.plugin.name}: ${verdict.reason}` }
14 }
15
16 const ran = await next(e)
17
18 // 2. After a successful Python edit: ruff check (read-only, never rewrites the file)
19 const isEdit = e.tool === 'Edit' || e.tool === 'Write'
20 if (!isEdit || !isPython(e.file_path) || ran.deny !== undefined || ran.isError || hasRuff === false) {
21 return ran
22 }
23 const lint = await $.process
24 .run(['ruff', 'check', '--output-format', 'concise', '--quiet', e.file_path], { timeoutMs: 20_000 })
25 .catch(() => undefined)
26 if (lint === undefined) {
27 hasRuff = false
28 $.ui.status('ruff not found — pip install ruff')
29 return ran
30 }
31 hasRuff = true
32 const name = e.file_path.split(/[\\/]/).pop()
33 const issues = lint.stdout.trim()
34 if (lint.exitCode === 0 || issues === '') {
35 $.ui.status(undefined)
36 return ran
37 }
38 const count = issues.split('\n').length
39 $.ui.status(`ruff: ${count} issue${count === 1 ? '' : 's'} in ${name}`)
40 return { ...ran, context: [...(ran.context ?? []), `ruff found issues in ${e.file_path} — fix them:\n${issues.slice(0, 4000)}`] }
41 }).catch(($, e, next) =>
42 // hook skipped (failure or re-entry): pure check only, treating the file as existing (fail closed)
43 next.called ? next(e) : checkFile(e.tool, e.file_path, true).isBlocked ? { deny: 'job-hunter-guard: protected file' } : next(e),
44 )
45
46 // 3. Bash guard
47 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
48 const verdict = checkBash(e.command)
49 return verdict.isBlocked ? { deny: `${$.plugin.name}: ${verdict.reason}` } : next(e)
50 }).catch(($, e, next) => {
51 // the hook was skipped (failure or re-entry): judge again with the pure rule, no $ calls
52 if (next.called) return next(e)
53 const verdict = checkBash(e.command)
54 return verdict.isBlocked ? { deny: `job-hunter-guard: ${verdict.reason}` } : next(e)
55 })
56}
57hooks/rules.ts 65 lines1// Pure rules, kept apart from the hooks so tests can check them directly.
2
3// .env and .env.local etc. — but not .env.example (meant to be read and edited)
4export const ENV_FILE = /(^|[\\/])\.env(\.(?!example$)[^\\/]+)?$/i
5// the SQLite DB and its WAL/journal siblings
6export const JOBS_DB = /(^|[\\/])data[\\/]jobs\.db(-wal|-shm|-journal)?$/i
7// the tailoring agent's source of truth
8export const MASTER_CV = /(^|[\\/])cv[\\/]cv_master\.ya?ml$/i
9
10const ENV_IN_CMD = /(^|[\s\\/'"=])\.env(?!\.example)(\.[\w-]+)?(?=$|[\s'";|&)])/i
11
12/** Bash commands that would destroy the application history or leak secrets. */
13const BASH_RULES: readonly { pattern: RegExp; reason: string }[] = [
14 {
15 pattern: /\b(rm|del|erase|rd|rmdir|remove-item|unlink|mv|move|move-item)\b[^|;&\n]*jobs\.db/i,
16 reason: 'data/jobs.db holds the application history (statuses, notes). Do not delete or move it.',
17 },
18 {
19 pattern: /(>|\bout-file\b|\bset-content\b)\s*[^|;&\n]*data[\\/]jobs\.db/i,
20 reason: 'Overwriting data/jobs.db would erase the application history.',
21 },
22 {
23 pattern: /\bdrop\s+table\b/i,
24 reason: 'DROP TABLE on the jobs DB is blocked. For schema changes add a migration (see the job-storage skill).',
25 },
26 {
27 pattern: /\bdelete\s+from\s+["'`]?(jobs|runs|tailored_cvs|profile)\b(?![^;]*\bwhere\b)/i,
28 reason: 'DELETE without WHERE would wipe a whole table. Add a WHERE clause or ask the owner.',
29 },
30]
31
32export type Verdict = { isBlocked: false } | { isBlocked: true; reason: string }
33
34export const checkBash = (command: string): Verdict => {
35 for (const rule of BASH_RULES) {
36 if (rule.pattern.test(command)) {
37 return { isBlocked: true, reason: rule.reason }
38 }
39 }
40 const readsEnv = /\b(cat|type|get-content|gc|less|more|head|tail|bat|grep|findstr|sed|awk|cp|copy)\b/i.test(command)
41 if (readsEnv && ENV_IN_CMD.test(command)) {
42 return { isBlocked: true, reason: '.env holds API keys and must not be read or copied. Use .env.example for the variable names.' }
43 }
44 return { isBlocked: false }
45}
46
47export const checkFile = (tool: 'Read' | 'Edit' | 'Write', path: string, exists: boolean): Verdict => {
48 if (ENV_FILE.test(path)) {
49 return { isBlocked: true, reason: '.env holds API keys; it is off-limits to Claude. Edit it yourself, or read .env.example for the variable names.' }
50 }
51 if (tool !== 'Read' && JOBS_DB.test(path)) {
52 return { isBlocked: true, reason: 'data/jobs.db is written only by the app (jobhunter/db.py), never edited directly.' }
53 }
54 // Creating the first draft is allowed (Phase 8 step 1); changing an existing one is the owner's call.
55 if (tool !== 'Read' && MASTER_CV.test(path) && exists) {
56 return {
57 isBlocked: true,
58 reason: 'cv/cv_master.yaml is the owner\'s source of truth for tailoring. Show the proposed change and ask the owner to apply it.',
59 }
60 }
61 return { isBlocked: false }
62}
63
64export const isPython = (path: string): boolean => /\.pyi?$/i.test(path)
65