Nothing in the framework checks a posting's language requirements
against what the candidate actually speaks. It is not one of the five
Scoring Dimensions in 04-job-evaluation.md, it is not checked in
/scrape's Step 3 fit assessment, and it is not a field in /rank's JSON
output - even though /apply's Step 1 already extracts a posting's
required language generically, with nowhere to report a mismatch to.
This adds a Language Gate, structured like the existing Eligibility
Gate (read the posting, classify against profile data, hard-stop on a
real mismatch), built on a new structured Languages table in CLAUDE.md
/ 01-candidate-profile.md. /setup now asks for it directly (Path C), or
infers it from a CV/LinkedIn export (Paths A/B - LinkedIn exports
already carry a self-rated Languages section).
The gate compares a posting's stated language requirements against
that table with three outcomes:
- Requires a language not declared at all -> hard FAIL, never
presented.
- Requires a higher level in a language that is declared (e.g. "fluent
English" against a declared B1/B2) -> FLAG, not an auto-reject -
scored and drafted normally, with the gap surfaced so the candidate
judges it themselves (a "fluent" bar reads very differently from a
strict employer vs. one that's flexible on it).
- Requires a language at or below the declared level -> clean PASS.
Wired through the three places that need it: /scrape (Step 3), /rank
(new language_gate/language_note fields alongside the existing
location veto - both are now persisted to seen_jobs.json, not just
used transiently to decide one run's shortlist), and /apply (Step 1's
language extraction now has somewhere to report to).
Out of scope, deliberately: this does not touch the free-form
Deal-breakers list or how it's used elsewhere (e.g. Scoring Dimension
4's relocation check) - that's a separate question this change takes
no position on.
Validated with two live-testing passes against real, unfetched
postings (not fabricated text) across 3 portals and 3 market languages
(Danish, German, Spanish/Argentina): 8/8 postings gated correctly in
the first pass, including ambiguous real-world wording ("you
communicate well in English") a rigid rule would have gotten wrong. A
second pass, run specifically to force a hard-FAIL case, found one
(a Danish posting requiring the ability to read Danish) and confirmed
it persists correctly and would be excluded from /rank's shortlist.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
/rank's Step 2 scoring agents already return strengths and gaps per job,
but Step 4 only persisted rank_score/rank_verdict/rank_date - both arrays
were printed once in Step 5 and then discarded. Store them verbatim in
seen_jobs.json (replaced, not accumulated, on --all re-ranks) so downstream
consumers can read real triage findings instead of re-deriving them.
Discussed in #258.
* feat(job-scraper): flag mass-posting and recycled-listing patterns
Adds Step 2.5 to detect two distribution patterns that are worth
surfacing to the user as a caution signal, not an accusation:
- Mass-posting: the same (or near-identical) listing posted across
many cities/locations at once, consolidated into one row instead of
presented as separate duplicate results.
- Recycled listing: a new candidate whose description closely matches
an older seen_jobs.json entry from the same company, but under a
different title.
Neither pattern is treated as proof of anything - fit is never
downgraded and results are never excluded because of it, the point is
giving the user the signal so they can decide. Explicitly scoped away
from naming companies as fraudulent (see the new Important Rules #8):
this documents a detectable behavior pattern, not a blacklist.
Motivated by real signal seen today: the same req ID posted across 6
different LATAM cities, and a single-city listing duplicated 3x under
slightly different titles.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Fix non-executable Pattern B: add snippet field, move check to Step 4
Pattern B instructed comparing a new posting's description against
existing seen_jobs.json entries, but the Step 4 schema never stored
descriptions - nothing existed to compare against, so the check
couldn't run as written.
- Adds an additive `snippet` field to the seen_jobs.json schema
(same move as #193's `portal` field), populated when each entry is
written.
- Moves the recycled-listing check itself to Step 4, where the write
happens, leaving Step 2.5 as the in-run mass-posting check only.
- Makes the snippet match the actual discriminator, not company +
different title alone - that alone would false-flag every company
that legitimately runs several concurrent open roles, which is
exactly what Rule 9's "signal, not accusation" framing is trying to
avoid.
- Updates the Step 5 / Important Rules cross-references accordingly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Trim to within-run mass-posting detection only
Drop the cross-run recycled-listing check (Step 4) and the snippet
field it depended on, per review: no evidence the pattern recurs
often enough to justify persisting a description snippet on every
seen_jobs.json entry permanently. Step 2.5's in-run mass-posting
consolidation is cheap and stays.
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Adds Step 4.75 to /scrape: detects the failure mode where a portal changes markup and its parser exits 0 with zero or garbled results (invisible to the Step 1c fallback, which only fires on non-zero exit). Free pass over this run's results (degraded scan) plus seen_jobs.json yield history; bounded escalation on suspicion only (the portal's own SKILL.md test query, one broader retry, rate-limit never treated as evidence); health: lines in the Step 5 summary with a confirmation-gated enabled:false quarantine offer; healthy portals stay silent. Adds a /scrape health [portal] probe-only mode. Persists a portal field in seen_jobs.json additively (per the /rank precedent), with read-time URL-domain attribution for pre-field entries so no migration is needed.
Folded into /scrape rather than a standalone /doctor command - the detection lives where the evidence (run results + yield history) already is, and a routine command catches rot a user would otherwise notice weeks late. By @ayobamiseun.
Implements the mechanism approved in discussion #93: enabled: true|false frontmatter on portal skills (missing key = enabled), honored during /scrape portal discovery, with skipped portals reported visibly in the run summary.
Rebased onto current master. The SKILL.md description and "How It Works"
copy were already generalized by #102, so this drops those now-redundant
edits and keeps only the unique remaining value: replacing the hardcoded
Danish job boards (jobindex.dk, karriere.dk, jobfinder.dk, akademikernes.dk)
and Danish location terms in search-queries.md with configurable
placeholders and an /add-portal pointer, so the query templates apply to
any market.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to #65 feedback after #52 merged CLI-first search. Step 2 now
uses each portal's detail command for CLI-sourced jobs (WebFetch only for
WebSearch fallbacks). Skill description reflects market-agnostic portal
CLIs instead of Danish-only wording.
Co-authored-by: Cursor <cursoragent@cursor.com>
The docs instruct users to run /scrape, but the skill's name was
job-scraper, so /scrape never resolved as a command - it only worked
via fuzzy trigger matching on the description. Renaming the skill's
name field makes /scrape a real, autocompleted command, consistent
with /upskill (whose skill is named upskill). Folder path unchanged.
Fixes#68. No wrapper command per the single-source-of-truth
precedent (#52).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- Untrack job_search_tracker.csv: it was both tracked and listed in
.gitignore (same inconsistency class as the settings.local.json fix
in #27). Users' personal rows risked merge conflicts on every pull;
commands already create the file with the standard header when it
is missing.
- Scope job-scraper's allowed-tools Bash entry (from #52) to
'bun --version' and the portal-CLI invocation pattern, adopting the
tighter form proposed in #65.
- Fix all five portal SKILL.mds documenting 'bun run skills/...'
paths that do not resolve from the repo root ('.agents/skills/...'
is correct) - now load-bearing since #52 wired /scrape to read
these docs for CLI invocations. Surfaced in #66.
- Teach tools/lint_skills.py to glob-expand allowed-tools bun run
targets so scoped wildcard permissions lint correctly.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: add /upskill command file to wire the upskill skill into Claude Code
The upskill skill and its full SKILL.md workflow already existed in
.claude/skills/upskill/SKILL.md, but there was no corresponding command
file in .claude/commands/. Without it, running /upskill in Claude Code
had zero structured behaviour — Claude would improvise with no defined
steps, mode detection, or output format.
This commit adds .claude/commands/upskill.md as the thin orchestration
layer that was missing:
- Step 0: Parses \ to determine aggregate mode (no args,
analyses all jobs in job_search_tracker.csv) vs. targeted mode
(a URL is passed, analyses that single posting). Unrecognised input
triggers a clarifying prompt rather than silently misbehaving.
- Step 1: In aggregate mode, reads the tracker and exits early with a
helpful message if it is empty, so the user is never dropped into a
broken analysis with no data.
- Step 2: Delegates all analysis work to the existing upskill SKILL.md
(hard skill diff, LLM synthesis, heatmap, web-searched resources,
study order, report save). No analysis logic is duplicated here.
- Step 3: Presents a concise post-run summary — critical/high gaps,
total estimated study time, and next-step suggestions (/scrape,
/apply, review the saved report).
Design principle: the command is intentionally a thin driver. All
substantive logic lives in SKILL.md so it remains in one place and
is easy to update independently of the command shell.
* fix: wire CLI tools into /scrape as primary search mechanism
The repo ships five Bun CLI search tools under .agents/skills/:
- jobindex-search (Jobindex.dk — largest Danish board)
- jobbank-search (Akademikernes Jobbank — academic/professional)
- jobdanmark-search (Jobdanmark.dk — broad coverage)
- jobnet-search (Jobnet.dk — government portal)
- linkedin-search (LinkedIn public jobs-guest API — country-agnostic)
Before this fix, none of them were ever called during /scrape. The
job-scraper SKILL.md told Claude to run WebSearch for everything,
meaning the CLIs were installed and documented but sat in dead-code
limbo with no callers.
Changes to .claude/skills/job-scraper/SKILL.md:
1. Added Bash to allowed-tools so the bun CLI commands are permitted
by Claude Code's tool-permission system. Without this, any attempt
to shell out would be blocked regardless of the instruction text.
2. Replaced the single WebSearch-only Step 1 with a three-part search
strategy:
Step 1a — bun availability check
Runs \un --version\ first. If bun is not installed the skill
gracefully degrades to WebSearch for all portals (Step 1c) and
notes the fallback in the results output, rather than crashing.
Step 1b — CLI tools as primary mechanism
For each query term extracted from search-queries.md, runs all five
CLIs with \--jobage 14 --limit 20 --format json\. Flags are
consistent with each tool's documented contract so output is
predictable. Each CLI call is independent: a non-zero exit or empty
result on one portal does not abort searches on the others. Results
are collected and merged before deduplication.
Step 1c — WebSearch fallback
Used for portals without a CLI skill (karriere.dk, jobfinder.dk,
company career pages via site: searches) and as the universal
fallback when bun is unavailable. This preserves backwards
compatibility for users who have not installed bun yet.
The net effect: /scrape now actually uses the CLI infrastructure the
repo was built around. WebSearch remains available for portals outside
the shipped skill set and for users on environments without bun.
* fix: rework /scrape CLI wiring + drop /upskill command file
Two changes addressing maintainer feedback on PR #52.
--- /scrape: use portal SKILL.md as source of truth ---
The previous approach hardcoded per-portal bun invocations directly
in job-scraper/SKILL.md. This broke in practice:
- jobbank requires --key (not --query); --query is not a valid flag
- jobnet uses --search-string and region/occupation filters; passing
--query silently returns the full unfiltered job firehose
- --sort date and uniform --jobage 14 are not supported by all portals
The fix removes all hardcoded per-portal flag examples. Instead, Step
1b now instructs the agent to:
1. Discover installed portal skills via .agents/skills/*/SKILL.md
2. Read each portal's own SKILL.md for its documented CLI interface
3. Translate search-queries.md terms into that portal's flag format
4. Use each portal's supported recency and limit flags
This makes the scraper self-maintaining: new portals added via
/add-portal are automatically included without any changes to this
file, and the scraper can never drift from the CLIs again.
The bun availability check (Step 1a) and Bash in allowed-tools are
preserved - both are still needed. The WebSearch fallback (Step 1c)
is preserved and cleaned up to cover: portals without a CLI skill,
any portal whose CLI fails at runtime, and the bun-unavailable case.
--- /upskill: drop command file ---
.claude/commands/upskill.md is removed. /upskill is deliberately
skill-hosted: .claude/skills/upskill/SKILL.md is the backing file
and parses its own /upskill vs /upskill <URL> modes (same pattern
as /scrape, which also has no command file). The command file created
a second entry point that duplicated the skill's argument parsing,
violating the single-source-of-truth principle established in #44
and #49.
---------
Co-authored-by: rajpratham1 <your-email@example.com>
/scrape finds and dedupes postings; /apply evaluates one at a time in
depth. Nothing connects the two ends: after a scrape returns 20 jobs, the
user eyeballs a table to decide where to spend /apply effort. /rank is the
bridge: batch-score every new posting against the fit framework and return
a ranked shortlist.
How it works:
- Selects jobs with status "new" from job_scraper/seen_jobs.json (--all
re-ranks everything unapplied; a focus argument filters), excluding
anything already in job_search_tracker.csv
- Dispatches parallel general-purpose agents (~5 jobs each) that WebFetch
each posting and score the five dimensions from 04-job-evaluation.md.
The rubric (skill match areas, career goals, deal-breakers) is passed
inline per the same token-efficiency rules /apply uses; agents score
only from actually fetched content and mark dead postings expired,
never guessing from a title
- Triage depth by design: posting text vs. profile only - no company
research, no salary lookups. /apply's Step 1 evaluation stays
authoritative and always re-runs on handoff
- Aggregates with the framework's 30/25/15/30 weighting and verdict
bands; location deal-breakers veto regardless of score; deadlines
within 7 days get urgency flags and win ties
- Updates seen_jobs.json additively (status "ranked"/"expired" plus
rank_score/rank_verdict/rank_date) so /scrape dedup keeps working;
the tracker is read-only. Re-running is idempotent
Integration: job-scraper SKILL.md documents the new status values and
suggests /rank after large scrape batches; README (commands list, file
tree, quick-start step 4).