mirror of
https://github.com/prdlk/jobyssey.git
synced 2026-11-02 13:12:49 +00:00
docs(docs): add initial project documentation and README files
This commit is contained in:
1 parent
8834f229f9
commit
49cc7f65da
13 files changed
+2366
No files matched your search
@@ -0,0 +1,19 @@
|
||||
---
|
||||
framework_version: 1.0.0
|
||||
---
|
||||
|
||||
# Agent Guidelines: AI Job Search
|
||||
|
||||
This workspace is structured to manage job search activities, scraper tools, CVs, cover letters, and interview preparation.
|
||||
|
||||
## Thin-Pointer Design (Single Source of Truth)
|
||||
|
||||
To prevent duplication and configuration drift across different AI agent frameworks (Claude Code, Google Antigravity, Codex, Cursor, Gemini CLI, etc.), this workspace uses a unified thin-pointer design. All agent runtimes should load the canonical specifications and candidate profiles from the files and directories below:
|
||||
|
||||
1. **Personal Candidate Profile:**
|
||||
- The candidate profile, contact details, education, and target preferences are defined in [CLAUDE.md](CLAUDE.md) and the individual profile methodology files under [.claude/skills/job-application-assistant/](.claude/skills/job-application-assistant/) (specifically `01-*.md` etc.).
|
||||
2. **Canonical Workflow Specifications:**
|
||||
- The step-by-step instructions and triggers for tasks (setup, scrape, rank, apply, upskill, interview) are defined in the [.claude/](.claude/) directory (specifically under `.claude/skills/` and `.claude/commands/`).
|
||||
- Do not duplicate these rules or specifications. Treat `.claude/` files as the single source of truth.
|
||||
3. **Portal Search Skills:**
|
||||
- Job-portal search CLIs live under [.agents/skills/](.agents/skills/) in the portable Agent Skills format (with a `SKILL.md` per portal). Codex and Antigravity discover these automatically; the `/scrape` workflow in [.claude/skills/job-scraper/](.claude/skills/job-scraper/) orchestrates them.
|
||||
Executable
+893
@@ -0,0 +1,893 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. The format is based on
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres
|
||||
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
Releases are vetted checkpoints of `master`. If you maintain a personalized fork,
|
||||
prefer updating to a tagged release over pulling raw `master` (see
|
||||
[SETUP.md, section 8](SETUP.md#8-pulling-upstream-updates-into-your-fork)). The
|
||||
`framework_version` markers on methodology files tell you which of your customized
|
||||
files a release touched; `python3 tools/check_upstream_updates.py` lists them with
|
||||
per-file diff commands.
|
||||
|
||||
## [1.6.0] - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Cross-portal `/scrape` contract pin** (#344) - a repo-level test deriving the Step 2
|
||||
search-output field list (`title`, `company`, `location`, `date`, `url`) from
|
||||
`job-scraper/SKILL.md`'s own contract sentence and checking every installed portal
|
||||
CLI's search source for it, so a portal that quietly stops emitting a contract field
|
||||
(the failure class jobnet and jobdanmark actually shipped before #339/#340) fails CI
|
||||
with a clean diff instead of degrading every `/scrape` run silently. The pin survived
|
||||
the #347 output-shape changes unmodified - evidence the derived-from-spec design holds.
|
||||
Contributed by @oscarbol09, the invited follow-up from #342's review.
|
||||
- **`freehire-search` gains `--no-description` for cheap discovery passes** - a default
|
||||
search hydrates full description bodies (~73% of the payload, ~20k tokens per query)
|
||||
while `/scrape` is told to pre-filter by title before reading bodies. The new flag
|
||||
drops the bodies (a live 10-result search shrinks from ~58k to ~10k chars) while
|
||||
keeping every other field; hydration stays the default. The API currently returns
|
||||
bodies regardless of `include_description=false`, so the lean guarantee is enforced
|
||||
client-side. Pinned in `tests/commands.test.ts`.
|
||||
- **Fixture coverage for linkedin's date/location and jobindex's `parseSearchPage`** -
|
||||
linkedin's search-card fixture carried no `<time>` or location element, so deleting
|
||||
the `date` extraction (a `/scrape` contract field on a default-ON portal) left every
|
||||
test green; jobindex's Stash parser had no tests at all, so `meta.total` could stop
|
||||
using `hitcount` unnoticed. Four new linkedin cases (both listdate class variants,
|
||||
location, absent-element nulls) and a new jobindex `search-page.test.ts` (hitcount
|
||||
vs page count, contract-field mapping, deadline fallbacks). Both mutation-verified.
|
||||
- **Tests for `check_framework_version.py`** - the CI gate that stops a framework file
|
||||
from being edited without a `framework_version` bump had zero tests, so the one-line
|
||||
mutation `return meaningful_changes > 0` -> `return False` disabled it while the suite
|
||||
stayed green. Four cases in the new `tests/test_check_framework_version.py` (clean
|
||||
tree, unbumped edit, bumped edit, missing marker), each running the real script inside
|
||||
an isolated git repo. Mutation-verified against that exact disable.
|
||||
- **Tests for `lint_skills.py`'s skill and command checks** - only `check_settings()`
|
||||
had coverage; the linter's main job (frontmatter keys, `allowed-tools` targets
|
||||
existing, the `# /<name>` command title rule) was unasserted, so deleting the
|
||||
missing-allowed-tools error survived the whole suite. Four new cases in
|
||||
`tests/test_lint_skills.py`, with the fixture's yaml stub upgraded to parse the real
|
||||
frontmatter. Mutation-verified.
|
||||
- **Discriminating tests for `robots_check`'s tie-break and browser-UA fallback** - the
|
||||
existing tie test put Disallow first, the one ordering that cannot detect deletion of
|
||||
the tie-break clause; and the browser-readback recovery that `09-web-research.md`
|
||||
claims is covered had no test at all. Three new tests in `tests/test_robots_check.py`
|
||||
pin the Allow-first tie, the 403-to-honest/200-to-browser recovery, and that a
|
||||
browser-fetched policy is still obeyed strictly. Each was mutation-verified: deleting
|
||||
the tie-break clause or the UA fallback now fails the suite.
|
||||
- **LaTeX special-character guidance for CVs** (`framework_version` 1.4.1 -> 1.4.2 in
|
||||
`05-cv-templates.md`, 1.0.1 -> 1.0.2 in `06-cover-letter-templates.md`) - `05` gains a
|
||||
"LaTeX Special Characters" section and `06`'s existing one is completed beyond `\_`/`\&`.
|
||||
The load-bearing case is an unescaped `%` in a quantified achievement bullet: it starts a
|
||||
LaTeX comment, so "cut latency by 40% and saved DKK 2M" compiles with zero errors and
|
||||
renders as "cut latency by 40" - silent content loss in the deliverable, on exactly the
|
||||
content the guidance steers users to write. `&` in employer names (Bang & Olufsen, H&M)
|
||||
fails loudly at compile time and is now documented alongside. Pinned by
|
||||
`tests/test_latex_guidance.py`.
|
||||
|
||||
- **`seen_jobs.json` entries record which mechanism produced them** - a new additive `source`
|
||||
field (`cli` for Step 1b portal-CLI output, `websearch` for the Step 1c fallback), a Step 1c
|
||||
rule tagging fallback results at collection time, and a `fallback (websearch):` line in the
|
||||
Step 5 run summary naming the portals that ran on the fallback. Motivated by the
|
||||
ghost-LinkedIn-jobs report (#331): when a stored job later turns out not to exist at its URL,
|
||||
triage hinges on whether the entry came from live CLI output or a search index that can be
|
||||
weeks stale - evidence that previously lived only in the run's scrollback. An entry that is
|
||||
missing `source` predates the field and is never backfilled; a presented job with no
|
||||
`seen_jobs.json` entry at all points at fabrication, which the scraper's Rule 1 forbids.
|
||||
Pinned by `tests/test_scrape_provenance.py`. `job-scraper/SKILL.md` sits outside the
|
||||
`framework_version`-marked set, so no version bump applies.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING (scripts passing stray flags): all six portal CLIs reject unknown flags**
|
||||
with exit 1 and `{"error", "code": "UNKNOWN_FLAG"}` on stderr, instead of silently
|
||||
discarding them. A discarded filter changes what a search returns with no error - a
|
||||
wrong flag name on jobdanmark returned the entire database (13,862 results, none
|
||||
matching) as if it matched the query, and the six portals use four different names for
|
||||
the free-text flag, so cross-portal guessing is likely. `add-portal.md` already
|
||||
required contributed portals to exit 1 on a bogus flag; the reference CLIs now meet
|
||||
their own bar. Pinned by nine new cases across the six `cli-flag-validation` suites.
|
||||
- **`/rank` persists its location verdict as `location_verdict`** - the bare `location`
|
||||
key meant two incompatible things in `seen_jobs.json`: a place (scraper search output,
|
||||
driving the commute filter) and a PASS/FAIL/FLAG verdict (`/rank` Step 4), so ranking
|
||||
could overwrite "Aarhus, Denmark" with "PASS" and no reader could tell which meaning a
|
||||
stored value carried. Legacy entries are read compatibly (a PASS/FAIL/FLAG string in
|
||||
`location` counts as the verdict when `location_verdict` is absent) and migrated on
|
||||
re-write. The `seen_jobs` schema note in `job-scraper/SKILL.md` now also enumerates
|
||||
`location_verdict`/`language_gate`/`language_note`, so its "do not drop any of these
|
||||
fields" instruction finally covers the fields `/rank` calls as important as the score.
|
||||
Pinned by two new tests in `tests/test_rank_command.py`.
|
||||
- **`linkedin-search detail` drops the `applyUrl` field** - the extraction regex
|
||||
assumed `class=` before `href=` and never matched LinkedIn's real markup (`null` on
|
||||
every live posting since the markup ordering differs), and fixing the regex would only
|
||||
capture the job-view URL, a duplicate of the record's own `url`. The field and the
|
||||
SKILL.md "apply link" claim are removed; a test pins the removal.
|
||||
- **`jobdanmark-search` search output drops presentation-only keys** - `coverImage`,
|
||||
`companyLogo`, `companyLogoSvgMarkup`, `overlayColor`, and `silhouetteLogo` were ~40%
|
||||
of a live payload (a 30-result response shrinks from ~30k to ~20k chars), fed into
|
||||
agent context on every `/scrape` query, and unusable by an agent. The #340
|
||||
compatibility duplicates (`companyName`, `publishedDate`, `applicationDeadline`) and
|
||||
`slug` stay. Pinned in `tests/search-normalization.test.ts`.
|
||||
- **BREAKING (jobbank forks): `jobbank-search` search output emits `deadline` as
|
||||
`YYYY-MM-DD`** - the feed's `DD.MM.YYYY` parenthetical was passed through raw,
|
||||
contradicting the `/scrape` contract, the other portals, and the same CLI's own
|
||||
`detail` command (which already emits ISO for the same job). `01.09.2026` is also
|
||||
ambiguous to a date parser (1 Sep vs 9 Jan). The known shape is now converted;
|
||||
"løbende" still maps to `null`, and an unrecognized shape passes through for `/rank`'s
|
||||
defensive handling. Anything parsing the old `DD.MM.YYYY` output must update - though
|
||||
the README's own search example already showed the ISO form. Pinned in
|
||||
`tests/rss-parsing.test.ts` and `tests/search-normalization.test.ts`.
|
||||
- **Job matching reframed around function, not title** (`framework_version` 1.2.2 -> 1.2.3 in
|
||||
`04-job-evaluation.md`) - title-lookalike matching throws away career capital that doesn't
|
||||
fit one job-title box (e.g. a background spanning research leadership, platform ownership,
|
||||
and program management gets collapsed into whichever single title sounds closest). `/setup`,
|
||||
`search-queries.md`, and `04-job-evaluation.md` now guide the candidate to define priority
|
||||
categories by function - the kind of problem a role solves - and to list several plausible
|
||||
job titles as query variants within each category, rather than betting an entire priority
|
||||
tier on one exact title string.
|
||||
|
||||
- **CONTRIBUTING: invited PRs are reserved for the invitee** - when a maintainer comment
|
||||
explicitly invites a named contributor to implement an issue they diagnosed or designed,
|
||||
the implementation is theirs for a stated window (default seven days, longer on request);
|
||||
a duplicate PR filed inside that window closes in the invitee's favor regardless of
|
||||
arrival order. Prospective from 2026-08-14. Sits alongside the existing credit norm.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Onboarding warns about public forks at the point of decision** (#345) - the quick
|
||||
start walked a new user into `gh repo fork` (forks of public repos are always public)
|
||||
and two steps later had `/setup` write personal data into tracked files, with the only
|
||||
complete warning sitting in SETUP.md section 8 - a section about pulling updates that a
|
||||
first-time user has no reason to open. A real user hit exactly this. The warning now
|
||||
sits adjacent to both fork commands (README step 1, SETUP.md section 2), and `/setup`
|
||||
checks the origin's visibility **before** writing anything: a public-fork origin gets a
|
||||
confirm-first warning instead of a note after every file is already on disk. Reported
|
||||
by @basilevs with a complete reproduction and fix analysis. Pinned by the new
|
||||
`tests/test_onboarding_privacy.py`.
|
||||
- **`jobindex-search detail` rewritten against jobindex's current markup** - every
|
||||
selector the old parser used is gone from live pages, so on 4 of 5 live postings it
|
||||
returned CSS-comment text as the deadline (`"K \t\t... */"`), an external ATS URL as
|
||||
its own `id` and `url`, null company/location/date, and a 160-char teaser as the
|
||||
description - exit 0 every time. The new parser handles both live shapes (the
|
||||
jobindex-native `jd-*` layout and the external-ATS passthrough), always keeps the
|
||||
jobindex id and `jobannonce` URL, requires a real date next to the deadline label and
|
||||
scans only visible markup (killing the CSS-comment capture), converts Danish long
|
||||
dates to ISO, and reports `company: null` honestly on passthrough pages instead of
|
||||
the ATS brand. Verified live on 5/5 postings (full descriptions of 5.5-9k chars, 4/5
|
||||
ISO deadlines and locations). Fixture tests for both shapes, including the
|
||||
CSS-comment trap, in the new `tests/detail-parsing.test.ts`.
|
||||
- **`/scrape` gains a recency fallback for portals with no recency flag** - Step 1b.3
|
||||
told every portal to scope to 14 days "using the portal's supported recency flag", but
|
||||
jobdanmark has none, leaving the instruction unsatisfiable there: the agent either
|
||||
silently skipped the scoping or invented a flag (which the CLIs now reject). Every
|
||||
portal emits a `date` field, so the instruction now says to filter client-side after
|
||||
the call, and stops presenting `--order` (a sort) as interchangeable with a filter.
|
||||
Pinned in `tests/test_scrape_provenance.py`.
|
||||
- **`/html-report`'s funnel counts stages from history; the rejection rate stops
|
||||
counting non-rejections** - the funnel was computed from current status, which is a
|
||||
state, not a history: an application that interviewed and was then rejected never
|
||||
counted as reaching Interview, so a finished search rendered as though nobody ever
|
||||
interviewed. The funnel (Step 2 and chart 4) now derives stage-reached from current
|
||||
status plus the `outcome.md` stage checkboxes Step 1.2 already merges. And the
|
||||
rejection rate no longer counts `offer_declined` (a success) or `withdrawn`
|
||||
(candidate-initiated) as rejections, nor unresolved Interview/Offer rows in its
|
||||
denominator. Pinned by two new tests in `tests/test_html_report_command.py`.
|
||||
- **`jobdanmark-search detail`'s HTML fallback emits the same shapes as its JSON-LD
|
||||
branch** - a posting without JSON-LD returned `datePosted` as the page's raw
|
||||
`DD-MM-YYYY` text, `validThrough` as free text (including the literal `"Løbende"`,
|
||||
which would flow into stored data as a deadline), and a hardcoded `null`
|
||||
`addressLocality`. The fallback now converts overview dates to `YYYY-MM-DD`, maps
|
||||
`Løbende` to `null` (jobbank's precedent for the equivalent), and derives the locality
|
||||
from the workplace address with the same postcode extraction search uses. Pinned in
|
||||
`tests/detail-parsing.test.ts`.
|
||||
- **`jobnet-search detail` no longer leaks the `1900-01-01` undisclosed-deadline
|
||||
sentinel** - `search` maps the API's sentinel to `null` (with a test pinning it), but
|
||||
`detail` dumped the raw response, so a posting whose deadline is simply not disclosed
|
||||
contributed a deadline 126 years in the past to stored data, and `/rank`'s expiry
|
||||
sweep would retire the job instantly. All three output formats now flow through a
|
||||
`prepareDetail` normalization that maps the sentinel to `null`. Pinned in
|
||||
`tests/detail-formatting.test.ts`.
|
||||
- **CI's placeholder guard now watches the CV's actual personal-data lines** - the
|
||||
sentinel for `cv/main_example.tex` was `[YOUR_NAME]`, whose only occurrences are a
|
||||
header comment and the hyperref `pdftitle`; `/setup`'s documented edit replaces the
|
||||
`\name{}`/`\address{}`/`\phone{}`/`\email{}` data and touches neither, so a fully
|
||||
personalized CV with a real name, address, phone and email passed the check (proven
|
||||
empirically in the review). The guard now asserts sentinels inside the `\name{}` and
|
||||
`\email{}` lines, and `01-candidate-profile.md`'s sentinel moves from the `<!-- SETUP`
|
||||
header comment onto the `[YOUR_EMAIL]` Identity field for the same reason. The new
|
||||
`tests/test_placeholder_integrity.py` simulates the `/setup` edit and requires the
|
||||
guard to fire on it.
|
||||
- **`jobindex-search` maps ASAP postings' deadline to `null`** - the portal's
|
||||
`apply_deadline_asap` flag was emitted as the literal string `"ASAP"` on roughly half
|
||||
of live results, contradicting the CLI's own README ("date string; null if not
|
||||
listed") and the `/scrape` schema, and breaking every consumer that does date
|
||||
arithmetic (`/rank`'s urgency and expiry sweep, `/outcome`'s deadline check,
|
||||
`/notion-sync`'s typed date column). ASAP means "no stated deadline", which the
|
||||
contract already represents as `null`. Pinned in `tests/search-page.test.ts`.
|
||||
- **`/gmail-sync` no longer restricts its search to the Inbox** - the query used
|
||||
`in:inbox` to "skip sent/drafts", but that operator also excludes every archived
|
||||
message, and self-defeatingly the mail matched by the very job-search label Step 3.1
|
||||
hunts for (the standard filter that applies such a label also archives). The query now
|
||||
uses `-in:sent -in:drafts`, which matches the stated intent exactly. The failure mode
|
||||
was silent under-detection: a missed rejection or interview invite read as "no
|
||||
updates". Pinned by the new `tests/test_gmail_sync_command.py`.
|
||||
- **`/upskill` no longer divides by a blank `fit_rating`** - `/outcome` creates tracker
|
||||
rows for applications made outside the workflow with no fit evaluation, so their
|
||||
`fit_rating` is blank, and Step 3.3's `(100 - fit_rating) / 100` had no rule for that.
|
||||
The naive blank-as-0 reading yields weight 1.0 (the maximum), letting the one job the
|
||||
framework knows nothing about dominate the skill-gap heatmap. A blank or non-numeric
|
||||
`fit_rating` now falls back to a matched ranked entry's `rank_score`, else the row is
|
||||
skipped, counted, and reported once - mirroring the skill's own missing-`gaps`
|
||||
handling. Pinned by `tests/test_upskill_skill.py`.
|
||||
- **`/rank`'s expiry sweep parses stored deadlines defensively** - the sweep changes
|
||||
status automatically from a date comparison against values on disk, but portals have
|
||||
shipped non-ISO shapes into `seen_jobs.json` (`"ASAP"`, `DD.MM.YYYY`, free text), and
|
||||
`/rank` had no rule for them while the display-only `/outcome` already did. A stored
|
||||
deadline that is not `YYYY-MM-DD` is now treated exactly like an absent one wherever a
|
||||
stored deadline is compared (urgency and sweep), and reported once with its portal.
|
||||
Pinned by `tests/test_rank_command.py`.
|
||||
- **Language Gate preamble no longer claims the gate is untracked** (`framework_version`
|
||||
1.2.3 -> 1.2.4 in `04-job-evaluation.md`) - the paragraph still said the result "is not
|
||||
a field `/scrape` or `/rank` track", written before the gate was wired into both
|
||||
consumers. An agent reading the authoritative framework file learned the opposite of
|
||||
what `rank.md` itself insists on ("These veto fields are as important to persist as
|
||||
the score itself"). The preamble now names `language_gate`/`language_note` and how each
|
||||
consumer uses them; a coupling test in `tests/test_rank_command.py` keeps the framework
|
||||
text honest about the tracking.
|
||||
- **`/reset documents` now clears `documents/postings/`** - the drop folder for
|
||||
hand-pasted job posting text was absent from the preview, the delete block, and the
|
||||
user-facing scope description, after which the command told the user "The `documents/`
|
||||
folder is now empty" - false whenever postings were present, and they are exactly the
|
||||
personal residue a reset exists to clear. A new `tests/test_reset_command.py` derives
|
||||
the folder list from the git tree, so any future drop folder fails the test until
|
||||
`/reset` covers it.
|
||||
- **`convert_salary_excel.py` no longer corrupts US/UK-formatted numbers 1000x** - the
|
||||
both-separators branch always assumed European locale, so a `"1,234.56"` cell was
|
||||
silently converted to `1.23456` and written into `salary_data.json`. The rule is now
|
||||
"the separator that appears last is the decimal separator", which also makes
|
||||
multi-group values (`"1,234,567.89"`, `"1.234.567,89"`) parse instead of raising. And
|
||||
`strip_type_patterns` now strips `COMPOUND_PATTERNS` words as substrings, mirroring
|
||||
`header_matches`, so a Danish compound header pair ("Antal alle" / "Lønindeks alle")
|
||||
pairs into one category instead of two unpaired standalones - the exact locale the
|
||||
compound support was added for. Pinned by six new cases in
|
||||
`tests/test_convert_salary_excel.py`.
|
||||
- **`jobdanmark-search` extracts the city when a comma follows the postcode** - the
|
||||
`location` regex required whitespace after the 4-digit postcode, but live
|
||||
`companyAddress` values frequently read `"2670, Greve"`; those results emitted
|
||||
`location: null` (7 of 30 in a live sample), so `/scrape`'s geography/commute filter
|
||||
(Rule 3) had nothing to act on. The extraction now accepts an optional comma, trims the
|
||||
captured city, and still refuses to mistake a 4-digit street number for the postcode.
|
||||
Pinned by three new cases in `tests/search-normalization.test.ts`.
|
||||
- **Example-CV bullets no longer swallowed as LaTeX optional labels** - every placeholder
|
||||
bullet written as `\item [text]` (11 in `cv/main_example.tex`, 3 in
|
||||
`06-cover-letter-templates.md`'s taught template) let LaTeX parse the bracketed text as
|
||||
`\item`'s optional argument: the shipped example CV rendered all Professional Experience
|
||||
bullets clipped off the left page edge, with the word "Achievement" appearing 9 times in
|
||||
the source and 0 times in the PDF text layer - a clean compile, green CI. Bullets are now
|
||||
braced (`\item {[text]}`), the cover-letter guide teaches the braced form, and CI's stock
|
||||
PDF assertions additionally require `Achievement` to survive `pdftotext`. Pinned by
|
||||
`tests/test_latex_guidance.py`.
|
||||
- **Documented ATS extraction commands pin `-enc UTF-8`** - `pdftotext -layout` without an
|
||||
encoding flag emits Latin-1 on Xpdf builds, so every non-ASCII character in a correct CV
|
||||
(Rambøll, Ingeniør, København) read back as a replacement character and failed the
|
||||
parseability checklist, steering the agent to "fix" a healthy document. The commands in
|
||||
`apply.md`, `05-cv-templates.md`, and `CLAUDE.md`'s verification checklist now carry
|
||||
`-enc UTF-8`, which is deterministic on both poppler and Xpdf. Pinned by
|
||||
`tests/test_latex_guidance.py`.
|
||||
|
||||
- **`jobbank-search` search output now carries the `/scrape` contract's `date` field** (#342) -
|
||||
the CLI emitted `posted` (full ISO 8601) but not the cross-portal `date` key, the one Step 2
|
||||
contract field it was missing. Search results now additively emit `date` as `YYYY-MM-DD`
|
||||
derived from `posted` (kept unchanged), `null` when the feed item carries no `pubDate`. The
|
||||
result mapping is extracted into an exported `normalizeSearchItem` so the derivation is
|
||||
pinned by tests. Completes the portal-contract series with #339 (jobnet) and #340
|
||||
(jobdanmark).
|
||||
|
||||
- **`jobdanmark-search` search output now carries the `/scrape` contract fields** - the CLI
|
||||
exposed the API-native schema (`companyName`, `publishedDate` in `DD-MM-YYYY`, …) with no
|
||||
`company`, `location`, `date` or `deadline`, so every `/scrape` run flagged jobdanmark as
|
||||
degraded and the `seen_jobs.json` dedupe lost the company. Search results now additively emit
|
||||
`company`, `location` (city after the postal code in `companyAddress`), and `date`/`deadline`
|
||||
in the `YYYY-MM-DD` convention, with null-safe handling of a missing address.
|
||||
|
||||
- **`jobnet-search` search output now carries the `/scrape` contract fields** - the CLI emitted
|
||||
the raw Jobnet API schema (`jobAdId`, `hiringOrgName`, `publicationDate`, …) with no
|
||||
`company`, `location`, `date` or `url`, so every `/scrape` run flagged jobnet as degraded
|
||||
forever (CI stayed green), the `seen_jobs.json` dedupe fell back to company+title, and `/rank`
|
||||
lost the posting link. Search results now additively emit `company`, `location`, `date`,
|
||||
`deadline` and `url` (`https://jobnet.dk/find-job/{jobAdId}` - the `/job/` route is
|
||||
login-walled); the API's `1900-01-01` "deadline not disclosed" sentinel maps to `null`.
|
||||
|
||||
- **A `/` in a company or role name no longer nests the application archive one level too deep**
|
||||
(jakob1379/ai-job-search#22). `Novo Nordisk A/S` derived
|
||||
`documents/applications/novo_nordisk_a/s_data_scientist/` - written and found by every command
|
||||
that derives the path, silently skipped by the two that enumerate it, so the application never
|
||||
appeared in `/html-report`'s dashboard and `/setup`'s calibration never learned from it. The
|
||||
**Subfolder naming** rule in `documents/README.md` now drops every character that is not a
|
||||
letter, digit or underscore (collapsing underscore runs, trimming the ends), and the derivation
|
||||
sites - `/apply`, `/outcome`, the direct application skill, `/gmail-sync`, `/interview`, and
|
||||
`/notion-sync` - cite that rule instead of paraphrasing it. An all-punctuation value that derives
|
||||
to an empty name now stops for user correction instead of writing into the archive root. The
|
||||
application assistant's `framework_version` moves 1.3.3 → 1.3.4. **Already-nested archives are
|
||||
not migrated**: an archive written under the old rule stays where it is until the user moves it;
|
||||
only newly derived names change. Thanks @jakob1379 for the report.
|
||||
|
||||
- **The `/html-report` dashboard now reads and renders the tracker's `deadline`** (follow-up to
|
||||
#319). The tracker gained a fourteenth `deadline` column and every other consumer (`/outcome`,
|
||||
`/upskill`, `/notion-sync`) was updated to know it, but the dashboard's Step 1 field
|
||||
enumeration and Step 3 table columns still listed the original thirteen - the one surface
|
||||
where the column could not be seen at all, so a `drafted` application's clock stayed invisible
|
||||
in the report that reviews the pipeline end to end. The Step 1 enumeration now matches the
|
||||
canonical 14-column header and the applications table can show a `Deadline` column, subject to
|
||||
the existing empty-column rule. Pinned by `tests/test_html_report_command.py` so a future
|
||||
column addition cannot silently vanish from the dashboard again.
|
||||
|
||||
- **Application deadlines are written down at every moment the framework provably holds them**
|
||||
(#319). `/scrape` fetched the deadline and rendered it in a table, `/rank` turned it into the 🔥
|
||||
urgency marker and the expiry check, and nothing stored it - so the marker fired exactly once,
|
||||
every later run had to re-fetch a posting that might have expired to recover the date, and a
|
||||
`drafted` application (whose only applicable clock is its deadline) had no time-based signal at
|
||||
all. `seen_jobs.json` entries now carry a `deadline` (base field, written on first sight,
|
||||
refreshed by `/rank` Step 4, `null` vs missing distinguished and never guessed); `/rank` Step 3
|
||||
re-derives urgency from the stored value with no re-fetch and sweeps already-ranked entries past
|
||||
their deadline into `expired`; the tracker gains a fourteenth `deadline` column appended last,
|
||||
with a header-line-only migration for existing trackers; `/apply` Step 0 extracts the deadline
|
||||
and Step 6b writes it (including the `/scrape` path via the assistant SKILL.md); `/outcome`
|
||||
surfaces it on open rows and flags near/passed deadlines on `drafted` rows without changing the
|
||||
no-follow-up rule; and the row-rewriting paths (`/outcome` Step 4, `/gmail-sync` Step 7a) now
|
||||
preserve every unparsed field so the new column survives the first status update. `/notion-sync`
|
||||
names the tracker as the Deadline source (tracker wins), `/upskill`'s column list stays true, and
|
||||
`job-application-assistant/SKILL.md` bumps `framework_version` 1.3.2 → 1.3.3. Pinned by
|
||||
`tests/test_rank_command.py`, `tests/test_apply_records_application.py`, and
|
||||
`tests/test_upskill_skill.py`.
|
||||
|
||||
The sweep's edges are stated rather than left to the reader: an entry with no stored `deadline`
|
||||
is left alone and never inferred from another field (the majority case, since most entries
|
||||
predate the column), `--all` re-scores any status including `expired` so a swept job is
|
||||
recoverable, and `/rank` Step 4's idempotency rule now names the sweep as its deliberate
|
||||
exception instead of contradicting it. Step 5 reports how many entries were swept and how many
|
||||
were retired, so an automated status change is never silent. `/outcome` Step 1 states that the
|
||||
header append is the one edit it may make outside a matched row, so it does not read as a
|
||||
violation of Step 4's own "never restructure the CSV". `/notion-sync` forbids reconciling two
|
||||
disagreeing deadlines by taking the earlier or later of them.
|
||||
|
||||
- **`convert_salary_excel.py` no longer misreads whole-thousands cells from a Danish-locale
|
||||
export** - a cell like `60.000` (thousands separator, no decimal comma) was handed to
|
||||
`float()` and silently written as `60.0`, a 1000x-wrong salary in `salary_data.json` that
|
||||
then rendered with a meaningless `vs baseline` percentage in `/apply`. The comma-side
|
||||
mirror (`1,234`) was already guarded as ambiguous and skipped; the dot side had no guard,
|
||||
and tests only pinned the both-separators form (`1.234,5`). `\d+\.\d{3}` is now rejected
|
||||
the same way, so the shared never-guess policy applies to both separators and the rows in
|
||||
between (e.g. `60.000,50`, `108,5`) keep parsing exactly as before. Pinned by
|
||||
`tests/test_convert_salary_excel.py`.
|
||||
|
||||
- **`main_example.tex` compiles on apt-packaged moderncv** (#242) - the banking template
|
||||
set its name styling through `\firstnamestyle`/`\lastnamestyle`, which moderncv 2.3.1
|
||||
(Debian/Ubuntu apt) does not have, so a fresh fork could not compile its own example CV
|
||||
on that toolchain. Name styling now routes through `\namefont`, the hook every name-style
|
||||
macro shares: live on every version (on 2.4+, head iii's `\firstnamestyle`/`\lastnamestyle`
|
||||
both route through `\namefont`, so the override is what sets the 34pt name there too), and
|
||||
the only option on 2.3.1 where those macros do not exist. Two review follow-ups landed in the
|
||||
same change: the `\hypersetup` comment now names the real clash mechanism
|
||||
(`\RequirePackage[unicode]{hyperref}` on < 2.4; `\PassOptionsToPackage`, introduced in
|
||||
2.4.0, is what removes the clash), and the metadata block sets `pdfpagemode=UseNone` - a
|
||||
`FullScreen` value there would win over the class's own `\AtEndPreamble` default and make
|
||||
every CV open in fullscreen presentation mode. `05-cv-templates.md`'s preamble copy stays
|
||||
in lockstep (framework_version 1.4.0 -> 1.4.1). Verified on moderncv 2.5.1: exit 0,
|
||||
exactly 2 pages, rendering unchanged.
|
||||
|
||||
## [1.5.0] - 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Commit-level upstream triage for forks** (#305). A new `tools/upstream_triage.py` walks the
|
||||
commits a fork is behind upstream and sorts them into "worth reviewing" vs "probably skip":
|
||||
cherry-picks already applied drop off on their own (matched by `git patch-id`, so ported work
|
||||
needs no bookkeeping), commits that only touch files the fork removed are set aside, and SHAs in
|
||||
a flat `.github/upstream-wontport.txt` stop resurfacing. It's the commit-history companion to
|
||||
`check_upstream_updates.py`'s version stamps - the two cross-reference each other in their output.
|
||||
Report-only by design: it prints ready-to-run `git cherry-pick` lines but never merges, pushes, or
|
||||
opens a PR, because on a fork "applies cleanly" isn't "correct". A `.github/workflows/upstream-watch.yml`
|
||||
runs it weekly into a rolling issue, guarded to no-op on the upstream template (pinned by a test) and
|
||||
scoped to the built-in `GITHUB_TOKEN` so it can never write outside its own fork. SETUP.md 8
|
||||
introduces both tools side by side. Offline tests cover patch-id matching, relevance filtering, the
|
||||
won't-port list, and the workflow guard. Thanks @anjolok1997.
|
||||
|
||||
- **`security_guards.py` now holds `.claude/settings.json` hooks to an allowlist** - the
|
||||
guard read `permissions.allow` and nothing else, so a `hooks` block in the same file
|
||||
passed silently. A hook is strictly more dangerous than a pre-approved permission: a
|
||||
permission pre-approves something Claude *may* choose to do, while a hook runs
|
||||
unconditionally when its event fires, with no prompt and no model decision in between.
|
||||
This is not hypothetical - it is the vector the Shai-Hulud worm used in its August 2026
|
||||
wave, planting a `SessionStart` hook in `.claude/settings.json` that executed on session
|
||||
start ([JFrog research](https://research.jfrog.com/post/shai-hulud-is-back-august/)).
|
||||
For a template thousands of people are invited to fork, that is the riskiest key in the
|
||||
file the guard already parses. `ALLOWED_HOOKS` ships empty (the template has no hooks),
|
||||
the check runs *before* the permissions shape guards so a malformed permissions block
|
||||
cannot return early and skip it, and unrecognised hook layouts fail closed rather than
|
||||
being skipped. Eight new `HookGuardTests` cases; 14 of the suite's 26 tests fail against
|
||||
the unpatched guard.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`/add-portal` now specifies how a generated skill handles an API token** (#304) - the command
|
||||
could already scaffold a skill for a portal reachable only through a paid fetching
|
||||
service, but said nothing about the credential such a skill needs. It now checks for that
|
||||
case during reconnaissance and raises the per-call cost with the user *before*
|
||||
scaffolding. That check is explicitly subordinate to the `robots.txt`/terms decision
|
||||
in Step 2.4 - a paid fetching service never launders a refusal, and the credential
|
||||
path exists only for portals whose `robots.txt` permits access but whose bot
|
||||
protection blocks ordinary fetches. The portal-skill contract requires the token to come from a
|
||||
`<SERVICE>_API_TOKEN` environment variable (never a CLI flag, never a fixture) and to
|
||||
fail with `MISSING_CREDENTIALS` when unset; and such a skill's `SKILL.md` must carry a
|
||||
Setup section naming the service, the variable, and the billing. Spec only - no shipped
|
||||
portal needs a credential, so no existing skill changes. Thanks @Haseeb-1698.
|
||||
|
||||
- **`/add-portal`'s fetching contract line now states the honest-UA posture** - it read
|
||||
"browser User-Agent", predating the repo-wide shift to honest self-identification
|
||||
(#283, #277 and the portal-CLI fixes that followed). A generated skill now defaults to
|
||||
`Mozilla/5.0 (compatible; <portal>-cli/1.0)` - the convention every shipped portal CLI
|
||||
follows - and escalation to browser headers goes through the robots.txt gate in
|
||||
`09-web-research.md`, never the CLI's default.
|
||||
|
||||
- **CI discovers portal CLIs instead of hardcoding them** (#310). The `cli-checks` matrix
|
||||
is now emitted by a `discover-clis` job that finds every `.agents/skills/*/cli/package.json`,
|
||||
so a portal skill added with `/add-portal` gets its `typecheck` and `test` scripts run by CI
|
||||
automatically - on this repo and on any fork - without editing the workflow. Upstream
|
||||
coverage is unchanged (the discovered list on `master` is exactly the six shipped portals).
|
||||
`/add-portal`'s Register step now says so. Thanks @ayobamiseun.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`/upskill` reports are now gitignored at the path the skill actually writes them to.**
|
||||
The ignore rule `upskill/*.md` is rooted (a middle slash anchors a gitignore pattern to the
|
||||
repo root), but `/upskill` is a *skill*, and skills resolve bare relative paths against
|
||||
their own directory - the same observed behavior the `**/job_scraper/*` rules exist for.
|
||||
A report written to `.claude/skills/upskill/upskill/report-*.md` was therefore not ignored
|
||||
(`git check-ignore` confirms it on the unpatched tree), and an upskill report is the
|
||||
candidate's skill gaps and weaknesses measured against named employers - among the most
|
||||
sensitive files the workflow generates. The obvious widening, `**/upskill/*.md`, would have
|
||||
ignored the template's own `.claude/skills/upskill/SKILL.md` (the skill directory shares
|
||||
the name), so the new rule pins the report-file prefix instead: `**/upskill/report-*.md`.
|
||||
Added to `.gitignore` and `security_guards.py`'s `REQUIRED_IGNORE_RULES`, with a
|
||||
`check-ignore`-based test pinning both properties - reports ignored at both depths,
|
||||
`SKILL.md` still tracked - which presence checks alone cannot see.
|
||||
|
||||
- **Dropped the phantom `evaluated` value from `seen_jobs.json`'s status vocabulary** (#315).
|
||||
The schema block in the job-scraper skill documented `new/skipped/evaluated/ranked/expired`,
|
||||
but `evaluated` has had no writer and no reader since the initial release - `new`/`skipped`
|
||||
come from `/scrape`, `ranked`/`expired` from `/rank`, and nothing ever set or selected
|
||||
`evaluated`. Post-#269 the tracker owns all lifecycle state after drafting, so the value had
|
||||
no future role either; it is now removed rather than wired up. `/rank` Step 1's `--all`
|
||||
wording ("all non-applied entries") leaned on an `applied` status the schema deliberately
|
||||
lacks and now names what it means: entries of any status, minus the tracker exclusion set.
|
||||
Forks that wrote their own tooling against the documented vocabulary should note the value
|
||||
was never produced by any shipped command.
|
||||
|
||||
- **`/apply` archives the job posting while it still holds it** (#306). `/apply` drafted two
|
||||
documents and a tracker row from the full posting, then let the text die with the session;
|
||||
`/outcome` Step 3.2 tried to recover it by re-fetching a `source` URL the spec itself expects
|
||||
to be dead, and a posting pasted from an email or a PDF had no `source` to re-fetch at all.
|
||||
Step 6b item 7 now writes the posting verbatim to
|
||||
`documents/applications/<company>_<role>/job_posting.md`, never a re-fetch or a
|
||||
reconstruction from memory; an existing file is left alone (a re-application to the same
|
||||
company and role keeps the earlier posting) and named in the report. Step 0 and the `/scrape`
|
||||
path (`job-application-assistant` SKILL.md Step 1) retain the full posting text, not a
|
||||
summary. Pinned by `tests/test_apply_records_application.py`.
|
||||
|
||||
- **Tracker status enum defined once; `offer declined`/`no response` now reach the correct
|
||||
`/html-report` bucket and `/gmail-sync` correctly marks them final** (#298). The tracker
|
||||
CSV `status` column had no single authoritative definition. Six command files restated it
|
||||
independently with inconsistent spellings, producing two concrete bugs:
|
||||
|
||||
- `/outcome` Step 4 wrote `no response` and `offer declined` (with spaces). `/html-report`
|
||||
Step 1 normalised only `no_response` / `offer_declined` (underscores), so any row written
|
||||
with spaces matched no bucket and was silently dropped from the rejection-rate denominator.
|
||||
- `/gmail-sync` Step 2 defined the "final" set with the space forms, so a row written with
|
||||
underscores was never recognised as final and the sync kept chasing closed applications.
|
||||
- `/html-report` included `interview_only` in the tracker bucket map; that value belongs to
|
||||
the archive `outcome.md` `Status:` field, not the CSV `status` column.
|
||||
|
||||
Fix: a `## Tracker status vocabulary` block in `/outcome` (the only writer of the CSV)
|
||||
now defines the canonical set once with underscore spellings and the **Final** set by
|
||||
explicit list — everything else, `drafted` included, is **Open**. The legacy space
|
||||
spellings are the same values, not separate statuses: equally **Final**, and every rule
|
||||
that names one form applies to the other — readers must accept them on read, and never
|
||||
write them. Every reader that makes final/open decisions references that block (`/apply`
|
||||
Step 6b, `/interview` Step 0, `/gmail-sync` Step 2, `/html-report` Step 1, `/notion-sync`
|
||||
Steps 3-4). `/outcome` Step 4 writes `no_response` / `offer_declined`; `/notion-sync`
|
||||
normalises both forms to the canonical spellings before setting the Status property;
|
||||
`/html-report`'s bucket map loses `interview_only`, keeps both spellings, and gains a
|
||||
case-insensitive catch-all that maps unrecognised values to **Rejected/Closed** and names
|
||||
them once in the status breakdown. Pinned by `tests/test_tracker_status_vocab.py`.
|
||||
|
||||
**Fork heads-up:** if your personalized `/outcome` adds `no response` or `offer declined`
|
||||
(space forms) to the tracker write path, swap them for the underscore forms. Existing rows
|
||||
keep working because every reader now accepts both spellings on read. If your Notion
|
||||
database already carries space-form Status options, they simply go unused — Notion never
|
||||
auto-removes select options.
|
||||
|
||||
## [1.4.0] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- **`--jobage-minutes` on linkedin-search for sub-day freshness windows** (#302) - LinkedIn
|
||||
filters its `f_TPR` parameter server-side at second granularity, so the CLI can now ask
|
||||
for postings from the last N minutes instead of whole-day windows only. Conflicts with
|
||||
`--jobage` are rejected explicitly (`CONFLICTING_AGE_FLAGS`). Useful for early-applicant
|
||||
freshness on high-volume searches; URL construction only, no parsing change.
|
||||
|
||||
- **README: video walkthrough link in Quick start** - The Next New Thing's hands-on
|
||||
walkthrough of the workflow (recorded August 2026), for newcomers who want to see the
|
||||
setup-to-application flow before reading. Docs only.
|
||||
|
||||
- **Spec-pinning tests for the Language Gate's `/rank` contract** (#278) - four regression
|
||||
guards in `tests/test_rank_command.py` pinning the `language_gate`/`language_note` fields
|
||||
through Steps 2-5 of `/rank`, including the Step 4 persistence rule that was live-debugged
|
||||
during #275 (vetoes reported in console output but `language_gate: null` on every persisted
|
||||
entry). Mirrors the existing `gaps`/`strengths` pinning pattern. No behavior change.
|
||||
|
||||
- **The jobnet and jobdanmark CLIs identify themselves on every API request** (#283) - their
|
||||
`apiFetch`/`apiPost` wrappers now send an explicit `User-Agent` (`jobnet-cli/1.0`,
|
||||
`jobdanmark-cli/1.0`) instead of Bun's anonymous default token, matching the honest
|
||||
self-identification jobindex already uses on `htmlFetch`. The new `user-agent.test.ts`
|
||||
suites assert the header on every request wrapper. No response behavior observed to
|
||||
change.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The four Danish demo portals now ship disabled** (#288) - `jobindex-search`,
|
||||
`jobbank-search`, `jobdanmark-search`, and `jobnet-search` default to `enabled: false`,
|
||||
and `/setup`'s job-portals question now acts on the answer: it flips them to
|
||||
`enabled: true` when your market is Denmark, and leaves them off otherwise. Previously a
|
||||
non-Danish user's `/scrape` ran all four Danish boards by default, spending tokens
|
||||
fetching and filtering irrelevant listings. **Fork heads-up:** if you search the Danish
|
||||
market, set `enabled: true` in those four `SKILL.md` files after updating (or re-run
|
||||
`/setup --section search`); forks that already curated their portal set are unaffected.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The linkedin-search CLI identifies honestly** - its `User-Agent` was a full Chrome
|
||||
browser string, the last portal CLI still spoofing after #283 and the jobbank/jobdanmark
|
||||
fix. It now sends `Mozilla/5.0 (compatible; linkedin-search-cli/1.0)`, the same token
|
||||
format as every other portal. Verified live on both the search and detail endpoints:
|
||||
identical 200 responses with full content under the honest token.
|
||||
|
||||
- **A `.env` was committable** (`.gitignore`, `tools/security_guards.py`). `/add-portal`
|
||||
can generate a skill for a portal that only returns usable content through a paid
|
||||
fetching service, and such a skill reads an API token from the environment - but
|
||||
nothing stopped the `.env` holding that token from being committed. No shipped portal
|
||||
needs a credential, so upstream never hit this; a fork whose generated portals do hit
|
||||
it immediately. `.env` and `.env.*` are now ignored and pinned in
|
||||
`REQUIRED_IGNORE_RULES`, so the guard fails if the rule is ever dropped.
|
||||
|
||||
- **The robots gate did not fail closed** (`tools/robots_check.py`, #277). Found by an
|
||||
adversarial review run over the merged file, not by inspection. Both cases are pinned
|
||||
in `tests/test_robots_check.py` as FAIL-OPEN REGRESSIONs:
|
||||
|
||||
- **A soft `200` granted permission.** A host answering `/robots.txt` with an HTML
|
||||
error page at status 200 produces a body that parses to zero rules, and zero rules
|
||||
read as "allowed" - so the browser-header retry ran on permission that was never
|
||||
given. A non-empty body carrying no recognised directive is now treated as
|
||||
unreadable. A genuinely empty file stays allow-all, per RFC 9309.
|
||||
- **`Disallow` patterns were never percent-decoded** while the request path was, so
|
||||
`Disallow: /foo%20bar` never matched `/foo bar` and the rule was silently skipped -
|
||||
a fail-open on any site that encodes its own rules.
|
||||
|
||||
- **`curl` argument hardening** (`tools/robots_check.py`). The curl argv had no `--`
|
||||
terminator before the URL. `gate()` rebuilds the target as `scheme://host/robots.txt`
|
||||
before calling `_fetch`, so the gate path was never exposed; this is hardening for
|
||||
direct callers, with a test pinning the terminator, that a dash-leading argument fails
|
||||
closed end to end, and that `gate()` never passes a caller-supplied URL through to
|
||||
curl. `--max-redirs 5` is set explicitly rather than left to curl's default.
|
||||
|
||||
- **Negative and fractional filter flags are rejected in the Danish portal CLIs** (#281) -
|
||||
`--jobage` (jobindex), `--radius` (jobnet), `--category`/`--jobtitle-id` (jobdanmark), and
|
||||
`--company` (jobbank) now validate as positive integers, completing the `page`/`limit`/
|
||||
`per-page` tightening from #191. Some portals silently ignore invalid filter values and
|
||||
return unfiltered results, so a mistyped ID produced wrong results instead of an error.
|
||||
- **The upstream checker reports files missing from the upstream ref instead of a silent
|
||||
`[OK]`** (#282) - if upstream renames or deletes a tracked framework file, a fork's
|
||||
`check_upstream_updates.py` now lists it under a `[WARNING]` summary instead of skipping
|
||||
it and printing a false all-clear.
|
||||
- **`09-web-research.md` is now tracked by the upstream checker** - the file shipped in
|
||||
#277 but was never added to `FRAMEWORK_FILES`, so forks got no signal when it changed.
|
||||
- **jobbank and jobdanmark CLIs identify honestly** - jobbank's `User-Agent` was a full
|
||||
Chrome browser string and jobdanmark's detail command sent a bare `Mozilla/5.0`; both now
|
||||
use the `Mozilla/5.0 (compatible; <portal>-cli/1.0)` token the other portal CLIs use,
|
||||
matching the identification posture settled in #277. Verified live: both portals serve
|
||||
identical responses to the honest token.
|
||||
|
||||
- **A `WebFetch` 403 is no longer treated as a dead posting** - `WebFetch` sends a bot user
|
||||
agent, and many bank and corporate sites answer it with HTTP 403 while serving the same
|
||||
page to a browser normally. Every command read that as "page unavailable" and degraded
|
||||
silently instead of failing loudly: `/rank` marked live postings `expired`, `/apply` fell
|
||||
back to search-result snippets or to vague cover-letter prose, and `/scrape` stored
|
||||
listing-page `#fragment` URLs that fetch fine but return unrelated jobs, breaking every
|
||||
later run on that entry. New `09-web-research.md` (`framework_version` 1.0.0) is the
|
||||
single reference: the trust boundary, a curl browser-header retry with a tag-stripping
|
||||
extractor, a four-step escalation order, the login-wall case, why the employer's own
|
||||
careers posting beats an aggregator listing (the requisition ID and the grade survive
|
||||
there), and the rule that a search snippet is a lead rather than a source. Wired into
|
||||
`/apply`, `/rank`, `/interview`, `/outcome`, `/notion-sync`, the job-scraper skill, and
|
||||
writing-style rule 5 (`03-writing-style.md` 1.1.0 to 1.2.0).
|
||||
|
||||
**The retry is gated on `robots.txt`.** `WebFetch` identifies itself as `Claude-User`
|
||||
and honors `robots.txt`, so a 403 means either a WAF default on a site whose published
|
||||
policy allows access, or a site that has actually declined. New `tools/robots_check.py`
|
||||
tells them apart and the escalation runs it before retrying: a disallow for `*` or
|
||||
`Claude-User` skips the retry entirely and goes straight to finding the employer's own
|
||||
posting. The rule is stated in the file so later edits do not erode it - *the retry
|
||||
exists to get past bot-filtering firewalls on sites whose robots.txt permits access; it
|
||||
is never used to override a site that has said no.* Two findings are pinned by
|
||||
`tests/test_robots_check.py` (15 offline cases): the WAF usually blocks `robots.txt`
|
||||
itself, so the policy is read as a browser when the honest request is refused and then
|
||||
obeyed strictly; and `urllib.robotparser` cannot be used, because it ends a record at a
|
||||
blank line and matches in file order, which reads a real-world policy as
|
||||
"everything allowed".
|
||||
|
||||
- **`/apply` now records the application in the tracker** - the flagship command wrote a CV
|
||||
and a cover letter to disk and then wrote nothing to `job_search_tracker.csv`, so a drafted
|
||||
and submitted application was invisible to `/gmail-sync`, `/html-report`, `/notion-sync`,
|
||||
`/interview`, `/upskill` aggregate mode, and to `/rank`'s dedup exclusion - and the safety
|
||||
net that would have caught it (`/gmail-sync`) refuses to create missing rows, so nothing
|
||||
detected the loss. A new Step 6b appends a `drafted` row carrying the two document paths,
|
||||
the fit rating and the posting URL, reusing `/outcome`'s exact header so the two commands
|
||||
cannot diverge; re-running `/apply` updates that row rather than duplicating it, unless every
|
||||
matching row holds a final status, in which case a second application to the same role gets
|
||||
its own row. The same
|
||||
step is mirrored into `job-application-assistant` because `/scrape` Step 5 routes straight
|
||||
into the skill (`framework_version` 1.2.0 -> 1.3.0), and `/scrape` Step 6 now defers to it
|
||||
instead of adding a row of its own. `seen_jobs.json` is deliberately left alone. **Forks:**
|
||||
the bump means `check_upstream_updates.py` will flag the skill - reconcile the new Step 3b
|
||||
(and Step 6b in `apply.md`) into your personalized copies rather than skipping the flag.
|
||||
|
||||
**`drafted` is introduced into the tracker status vocabulary**, and every reader that
|
||||
meant *submitted* now says so. These readers define "open" by exclusion from the final
|
||||
statuses, so a new non-final value would otherwise have joined all of them silently:
|
||||
`/outcome`'s follow-up branch no longer drafts a chase email for an application that was
|
||||
never sent, `/gmail-sync` no longer reports unsent drafts as stale, `/notion-sync` leaves
|
||||
"Applied on" empty for them and says "not yet submitted" in the page body rather than
|
||||
calling drafts submitted documents, and `/html-report` gains a sixth **Drafted** bucket
|
||||
kept out of the funnel, the rejection rate and the headline count. `/outcome` Step 4
|
||||
overwrites `date` with the submission date when a row leaves `drafted`, so the column
|
||||
keeps meaning "applied on".
|
||||
|
||||
**`/gmail-sync` deliberately keeps searching for drafted rows.** `/apply` drafts but the
|
||||
user submits, and forgetting to run `/outcome` afterwards is the failure this issue is
|
||||
about. An employer reply arriving against a row still marked `drafted` is how that gets
|
||||
caught, so those rows stay in the search set, the application acknowledgement is promoted
|
||||
from noise to a `drafted` -> `applied` signal (it is the one email that proves a hand
|
||||
submission, and it arrives within a day of it), and an approved match corrects the `date`
|
||||
as well as the status. Only the staleness check skips them, since nothing was sent. (#269)
|
||||
|
||||
## [1.3.0] - 2026-08-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Language Gate** - no dimension or gate anywhere in the framework checked a posting's
|
||||
language requirements against what the candidate actually speaks (not a Scoring Dimension,
|
||||
not a `/scrape`/`/rank` field, nothing for `/apply`'s existing generic language detection
|
||||
to report to). Adds that check, structured like the existing Eligibility Gate, on a new
|
||||
structured `Languages` table in CLAUDE.md / `01-candidate-profile.md` (`/setup` asks, or
|
||||
infers it from a CV/LinkedIn export): a posting requiring a language you haven't declared
|
||||
at all is a hard **FAIL**; one requiring a higher level than you declared in a language you
|
||||
*do* work in is **FLAG**, not an auto-reject, so borderline cases (a strict "fluent" bar vs.
|
||||
your own B1/B2) get your judgment instead of a silent drop; a requirement at or below your
|
||||
declared level is a clean **PASS**. Wired through `/scrape`, `/rank`, and `/apply`, with
|
||||
`language_gate`/`language_note` persisted into `seen_jobs.json` alongside the existing
|
||||
`location` veto so a re-read of the file (or a future debugging session) can recover why a
|
||||
job did or didn't make the shortlist.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CV date fields now use ASCII hyphens, so the PDF text layer extracts cleanly** - the
|
||||
stock template wrote date ranges as `[YYYY--YYYY]`, and on the repo's mandated `lualatex`
|
||||
toolchain the `--` en-dash ligature extracts from the PDF as U+FFFD (`�`). The stock
|
||||
template therefore failed the ATS checklist's own "no `�` replacement characters" item on
|
||||
*every* date field, and did so silently: the rendered page looks correct, and no existing
|
||||
check inspected the extracted text. `cv/main_example.tex` now uses `[YYYY-YYYY]` and
|
||||
`[YYYY-Present]`, and `05-cv-templates.md` documents the failure mode and the check that
|
||||
catches it (`framework_version` 1.3.0 to 1.4.0). The two-page layout budget is unaffected.
|
||||
|
||||
**Fork reconciliation note.** The five changed lines in `cv/main_example.tex` are the
|
||||
`\cventry` date fields - three under Professional Experience, two under Education -
|
||||
precisely the lines every fork personalizes. Rebasing forks should expect conflicts there,
|
||||
resolve them in favour of *their own* dates, and then apply the same `--` to `-` change by
|
||||
hand. To find remaining instances across your own CV variants:
|
||||
|
||||
```
|
||||
grep -rn '\\cventry{[^}]*--' cv/
|
||||
```
|
||||
|
||||
Verify afterwards by extracting the text layer and checking the date lines specifically:
|
||||
`pdftotext -layout <file>.pdf - | grep '�'` - none of the hits may be a date field. (On
|
||||
the stock template two benign hits remain either way: the decorative separators on the
|
||||
contact and award lines, which are unrelated to dates and predate this fix.)
|
||||
|
||||
- `tools/convert_salary_excel.py` now parses localized numeric string cells - Excel
|
||||
exports that store numbers as text (a Danish `"108,5"`, `"1.234,5"`, or space-separated
|
||||
thousands) previously hit `float()`'s `ValueError` and were silently dropped from
|
||||
`salary_data.json`. The ambiguous single-comma-plus-three-digits pattern (`"1,234"`,
|
||||
thousands in one locale and a decimal in another) is deliberately skipped rather than
|
||||
guessed, preserving the old safe behaviour for the one case that cannot be
|
||||
disambiguated. (#272)
|
||||
- `tools/check_upstream_updates.py` compares the template-repo slug case-insensitively -
|
||||
GitHub serves repository paths case-insensitively, so a clone made from a lowercased
|
||||
URL was a legitimate direct clone that nonetheless triggered #265's fork-vs-self
|
||||
warning. (#273)
|
||||
|
||||
### Changed
|
||||
|
||||
- SETUP.md section 8 now shows the first-time `git remote add upstream ...` command
|
||||
before telling you to `git fetch upstream`, which previously failed on any clone of a
|
||||
personal fork with no explanation of the missing remote. (#274)
|
||||
|
||||
### Security & privacy
|
||||
|
||||
- **The gitignore guard now covers every personal-output rule** - `security_guards.py`
|
||||
additionally requires the ignore rules for Gmail sync state (`gmail_sync/`), generated
|
||||
dashboards (`reports/`), upskill reports (`upskill/*.md`), Notion sync state
|
||||
(`**/job_scraper/notion_sync.json`), pasted postings (`documents/postings/**`), scraper
|
||||
markdown output (`**/job_scraper/*.md`), and behavioral-report / LinkedIn-profile PDFs.
|
||||
With these, every `.gitignore` rule outside the guard's required list is build tooling
|
||||
noise, so any future weakening of the personal-data boundary fails CI. All rules were
|
||||
already present in `.gitignore`; the guard now enforces the full set. (#271)
|
||||
|
||||
## [1.2.0] - 2026-08-01
|
||||
|
||||
### Added
|
||||
|
||||
- **`/rank` now persists `strengths` and `gaps` into `seen_jobs.json`** - Step 2's scoring
|
||||
agents already produced both arrays per job; Step 4 previously kept only `rank_score`,
|
||||
`rank_verdict`, and `rank_date`, so the honest per-posting findings were printed once in
|
||||
Step 5 and then discarded. Both arrays are now stored verbatim and replaced (never
|
||||
accumulated) on `--all` re-ranks, so downstream consumers of `seen_jobs.json` can read
|
||||
real triage findings instead of re-deriving them. See
|
||||
[discussion #258](https://github.com/MadsLorentzen/ai-job-search/discussions/258).
|
||||
- **`/upskill` aggregate mode now ingests `/rank`'s recorded gaps** - previously it only
|
||||
read `job_search_tracker.csv` and *guessed* required skills from the `role`/`sector`/
|
||||
`notes` columns, even though `/rank` had already fetched and scored postings that never
|
||||
made it into the tracker. Aggregate mode now also reads ranked entries
|
||||
(`rank_score >= 45`) from `job_scraper/seen_jobs.json`, dedupes them against tracker rows
|
||||
on case-insensitive company+role, and prefers a job's recorded `gaps` over an inferred
|
||||
skill list wherever both exist. The heatmap's Gap Source column now shows the
|
||||
recorded-vs-inferred split per skill, and the report header states how many jobs came
|
||||
from each source. Depends on #263 (`/rank` persisting `gaps`/`strengths`); see
|
||||
[discussion #258](https://github.com/MadsLorentzen/ai-job-search/discussions/258).
|
||||
|
||||
### Security & privacy
|
||||
|
||||
- **SETUP.md no longer calls a fork "private working space"** - forks of public GitHub
|
||||
repositories are always public, so that wording invited exactly the personal-data
|
||||
exposure it seemed to rule out. Section 8 now states the fork-is-public fact plainly and
|
||||
documents the safe alternative (a private repository with this repo as `upstream`), and
|
||||
`/setup` ends with a matching privacy note the moment profile data first lands in
|
||||
tracked files. Prompted by
|
||||
[discussion #266](https://github.com/MadsLorentzen/ai-job-search/discussions/266).
|
||||
- **The gitignore guard now covers two more personal-data rules** - `security_guards.py`
|
||||
requires `cover_letters/Cover_*.*` (the uppercase cover-letter naming variant `/apply`
|
||||
recognizes) and `cv/*.txt` (ATS text extractions of tailored CVs) in `.gitignore`, so a
|
||||
future change weakening either rule fails CI instead of silently making personal files
|
||||
trackable. Both rules were already present in `.gitignore`; only the guard lagged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `tools/check_upstream_updates.py` no longer reports a false "up to date with upstream"
|
||||
when it silently falls back to a fork's own `origin` remote - the default state of a
|
||||
plain fork clone, where the script compared the fork against itself and could never
|
||||
detect upstream updates. It now warns that the fallback remote is not the template repo,
|
||||
shows the `git remote add upstream` command to fix it, and names the ref it actually
|
||||
compared against. (#265)
|
||||
- Removed the vestigial `cover_letters/OpenFonts/cover.cls` - an unreferenced remnant of
|
||||
the original font bundle that, since #252's class rename, ambiguously declared the same
|
||||
`cover` class as the real `cover_letters/cover.cls`.
|
||||
- Added regression tests pinning #252's ragged-row bounds fix in
|
||||
`tools/convert_salary_excel.py` (dimension-less workbooks read in `read_only` mode
|
||||
yield rows shorter than the header).
|
||||
|
||||
### Changed
|
||||
|
||||
- CONTRIBUTING's "run what CI runs" list is now complete - it previously omitted
|
||||
`tools/security_guards.py` and the exact `unittest` invocation, the precise checks a
|
||||
contributor PR had already failed on. Prompted by
|
||||
[issue #262](https://github.com/MadsLorentzen/ai-job-search/issues/262).
|
||||
|
||||
## [1.1.0] - 2026-07-30
|
||||
|
||||
### Security & privacy
|
||||
|
||||
- **Personalized custom-template files are now gitignored regardless of engine** - the
|
||||
ignore rules broadened from `cv/main_*.tex` to `cv/main_*.*` (and likewise for cover
|
||||
letters), so a fork using a Typst or other non-LaTeX template no longer commits
|
||||
personalized `main_<company>.typ` files to a public fork. The `*_example.tex` files stay
|
||||
tracked. If you registered a custom template before this release, check
|
||||
`git status` once after updating. (#238)
|
||||
- **Dependency review is live, for forks too** - the repo's Dependency graph is now enabled,
|
||||
so the CI `dependency-review` job actually blocks PRs that introduce dependencies with
|
||||
known high-severity vulnerabilities, and the job is no longer gated to the upstream repo:
|
||||
forks get the same check, self-activating if the fork enables Dependency graph
|
||||
(it warns-and-passes otherwise). (#254)
|
||||
|
||||
### Added
|
||||
|
||||
- **freehire-search: full descriptions come back with the search** - `search` now calls
|
||||
freehire's agent search endpoint (`/api/v1/agent/jobs/search`), which serves each hit's
|
||||
complete description instead of the search index's truncated preview. A 20-role search is
|
||||
one request rather than 1 + 20 `detail` calls, and `/scrape`'s Step 2 no longer needs a
|
||||
per-hit fetch for this portal. `--description-format markdown|text|html` (default
|
||||
`markdown`) selects the rendering; `table` and `plain` output is unchanged. (#251)
|
||||
- **Custom templates: any compile-to-PDF toolchain (Typst, ...)** - `/add-template` no longer
|
||||
hardcodes a `lualatex`/`xelatex`/`pdflatex` engine enum. Custom templates now declare a
|
||||
source extension and a full compile command, so Typst (`typst compile`) registers the same
|
||||
way a custom LaTeX template does. Stock CV/cover letter templates stay LaTeX,
|
||||
unchanged. (#238)
|
||||
- **Application-form fields as an optional third `/apply` artifact** - when a posting's
|
||||
application form asks screening questions, `/apply` can now offer a prep sheet of
|
||||
grounded answers alongside the CV and cover letter. Opt-in; the default two-document
|
||||
output never changes. (#212)
|
||||
- **Confirmed facts write back to the profile** - when `/apply` or `/interview` surfaces a
|
||||
fact the user confirms (a skill, a date, a project detail), it is written back to the
|
||||
profile files in the same turn instead of being lost with the conversation. (#211)
|
||||
- **CV methodology: in-progress qualifications and tenure-vs-output** - `05-cv-templates.md`
|
||||
gains explicit rules for stating in-progress certifications/degrees honestly and for
|
||||
checking claimed tenure against visible output (`framework_version` 1.2.1 -> 1.3.0). (#210)
|
||||
- **Scraper flags mass-posting and recycled-listing patterns** - `/scrape` marks postings
|
||||
that look bulk-posted or recycled so they don't eat evaluation effort. (#207)
|
||||
- **Retry contract pinned in CI** - all six portal CLIs now carry 429/5xx retry-backoff
|
||||
tests covering every fetch wrapper, so a silent regression in retry behavior trips
|
||||
CI. (#246)
|
||||
- **README: the extension model, documented** - new Customization subsection "Extending the
|
||||
framework: portals, templates, criteria - and borrowing from other forks": the three
|
||||
extension points, the copy-one-folder pattern for borrowing a portal skill from another
|
||||
fork with a read-the-code-first checklist, and why there is deliberately no installer
|
||||
(the manual copy is the security model). Prompted by discussion #249.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `/rank` shortlist and below-threshold tables include each posting's URL. (#236)
|
||||
- `convert_salary_excel.py`: count/index columns pair by category name instead of
|
||||
adjacency (#219), standalone count columns store as counts (#230), and ragged rows from
|
||||
dimension-less spreadsheets no longer crash with an IndexError (#252).
|
||||
- `cover.cls`: duplicate package imports removed and the `\ProvidesClass` name fixed to
|
||||
match the filename, silencing a class-name-mismatch warning. (#252)
|
||||
- Portal CLI type-checking pinned to concrete `@types/bun` / `@bunli/*` versions to stop
|
||||
environmental CI type-drift. (#226)
|
||||
- `freehire-search` points at freehire.me after the service's domain migration. (#229)
|
||||
- `verify_pdf.py`'s missing-poppler error now includes per-OS install hints. (#252)
|
||||
|
||||
## [1.0.0] - 2026-07-22
|
||||
|
||||
First tagged release. This marks the framework as stable and gives forks a described
|
||||
checkpoint to update against instead of a moving `master`. It is a baseline of what
|
||||
already exists rather than a set of new changes; subsequent releases will document
|
||||
what changed since the previous tag.
|
||||
|
||||
At this baseline the framework provides:
|
||||
|
||||
- **Application workflow** - a drafter/reviewer `/apply` pipeline (CV + cover letter),
|
||||
plus `/setup`, `/scrape`, `/rank`, `/interview`, `/outcome`, `/upskill`,
|
||||
`/expand`, `/html-report`, `/gmail-sync`, `/notion-sync`, `/add-portal`,
|
||||
`/add-template`, and `/reset`.
|
||||
- **Portal search skills** - country-agnostic job-board CLIs (LinkedIn, freehire, and
|
||||
the Danish boards) in the portable Agent Skills format under `.agents/skills/`,
|
||||
discovered and orchestrated by `/scrape`, with an `enabled:` toggle for skipping
|
||||
portals.
|
||||
- **Framework versioning** - `framework_version` markers on methodology files plus
|
||||
`tools/check_framework_version.py` (CI guard) and `tools/check_upstream_updates.py`
|
||||
(fork-side update preview).
|
||||
- **Privacy and safety guards** - `.gitignore` protection for personal data, the
|
||||
`tools/security_guards.py` allowlist for `.gitignore` negations, and a CI policy of
|
||||
making no live portal requests.
|
||||
- **Cross-runtime support** - a root `AGENTS.md` pointer so Codex and Antigravity can
|
||||
discover the portable portal skills, with Claude Code as the reference runtime.
|
||||
|
||||
[Unreleased]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.6.0...HEAD
|
||||
[1.6.0]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.5.0...v1.6.0
|
||||
[1.5.0]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.4.0...v1.5.0
|
||||
[1.4.0]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.3.0...v1.4.0
|
||||
[1.3.0]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.2.0...v1.3.0
|
||||
[1.2.0]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.1.0...v1.2.0
|
||||
[1.1.0]: https://github.com/MadsLorentzen/ai-job-search/compare/v1.0.0...v1.1.0
|
||||
[1.0.0]: https://github.com/MadsLorentzen/ai-job-search/releases/tag/v1.0.0
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for considering a contribution! This repo has a deliberate, narrow philosophy, and most declined PRs are well-executed work that simply didn't know about it. Read this first; it will save you effort and tell you where your work will land best.
|
||||
|
||||
## The one rule everything follows from
|
||||
|
||||
**This repo is a universal template.** People fork it and adapt it to their own market, language, and profile. Upstream stays market-agnostic, person-agnostic, and Claude Code-native. The corollary: a contribution is judged by fit to this rule first, execution quality second. Well-built but off-policy still gets declined (kindly, with reasons).
|
||||
|
||||
## What gets merged
|
||||
|
||||
- **Universal customization features**: anything that makes the fork-and-adapt path better for everyone. Precedent: `/add-template` ([#30]), `/add-portal` ([#37]).
|
||||
- **Robustness and correctness fixes** with the failing case demonstrated. Precedent: NaN flag validation ([#35]), HTML entity decoding ([#55], [#56]), salary column detection ([#64]).
|
||||
- **Docs that close real gaps**: platform-specific setup ([#41], [#60]), stale references ([#36], [#68]).
|
||||
- **Infrastructure that reduces review burden** and is argued from evidence, not speculation. Precedent: CI ([#59]), which caught a latent bug while being built.
|
||||
|
||||
## What gets declined
|
||||
|
||||
- **Market- or country-specific skills and content.** One country's portal opens the door to every country's portal; there is no principled stopping point. Precedent: [#31] (India), [#39] (France, despite an honest and excellent PR), [#67] (China). The in-tree portal skills are either country-agnostic (`linkedin-search`) or the maintainer's own demonstration instance (the Danish portals).
|
||||
- **Personal profile data.** The template ships placeholders; your populated profile lives in your fork. CI enforces this (`placeholder-integrity`). Precedent: [#17], [#72].
|
||||
- **Alternative-harness ports and duplicate workflow sources.** The markdown specs ARE the implementation; a second copy (another agent CLI, an orchestration layer, a wrapper command) drifts from the first the moment either changes. Precedent: [#44], [#49], [#66].
|
||||
- **Speculative infrastructure.** Complexity must be argued from a problem that exists, not one that might. Precedent: [#63].
|
||||
- **Kitchen-sink PRs.** One concern per PR. Bundles get asked to split ([#73]) - and splits get reviewed fast ([#75], [#76] arrived within the hour and were handled same-day).
|
||||
|
||||
## The bar for new commands
|
||||
|
||||
The core lifecycle is **feature-complete**: `/setup` → `/scrape` → `/rank` → `/apply` → `/interview` → `/outcome` → calibration back into `/setup`, with `/expand`, `/upskill`, `/add-template`, `/add-portal`, and `/reset` around it. Every stage of a real job hunt has an owner.
|
||||
|
||||
A new command therefore faces a high bar. The test that admitted the existing ones: **does it operationalize something error-prone that already exists in the framework** (documented machinery nothing executes, data something writes but nothing reads)? "Useful" and "possible" are not sufficient; the strongest proposals connect two things that already exist without modifying either ([#43], [#54]).
|
||||
|
||||
## Claims get verified
|
||||
|
||||
Reviews here are empirical. Bug reports are reproduced on master before the fix is considered; "all tests green" is checked against whether the tests can distinguish master from the fix. PRs whose premise doesn't reproduce get declined even when the code is fine - it has happened ([#35]'s converter fix, [#52]'s first version). You can make this fast:
|
||||
|
||||
- State the failing case and how to reproduce it.
|
||||
- **Reproduce on the real path, not a constructed input.** A test that fails on master and passes on the fix is necessary but not sufficient: the failing input has to be one the workflow actually produces, not one the test hand-builds. Show the failure through the path the code really runs - the documented CLI invocation, real portal output, an actual data file - not a synthetic value fed straight to the function. A fix whose only demonstration is an input the real code path never receives gets declined even though its test is green.
|
||||
- Put CLI tests in `.agents/skills/<name>/cli/tests/` (bun test, network-free where possible); Python tool tests in `tests/`.
|
||||
- Run what CI runs: `python3 tools/lint_skills.py`, `python3 tools/check_framework_version.py`, `python3 tools/security_guards.py`, `python3 -m unittest discover -s tests`, and in touched CLIs `bun run typecheck` + `bun test`.
|
||||
|
||||
**Credit norm:** a change that incorporates your actual code gets a `Co-authored-by` trailer; a change written independently from your observation or report gets a named mention in the commit message and PR. Both happen unprompted.
|
||||
|
||||
**Invited PRs:** when a maintainer comment explicitly invites a named contributor to file the PR for an issue they diagnosed or designed, that invitation reserves the implementation for them - by default for seven days from the invite, longer when they say they are working on it. A duplicate PR filed inside that window will be closed in favor of the invitee's, regardless of arrival order or polish. Review, test, and comment on an invited PR all you like - that multiplies the work; racing it doesn't. (Prospective from 2026-08-14.)
|
||||
|
||||
## Building for your own market? Do this instead
|
||||
|
||||
1. Fork the repo and run `/add-portal` with your local job board - it scaffolds a portal skill matching the shipped contract, and `/scrape` picks it up automatically.
|
||||
2. Announce your fork in the pinned [Community forks & adaptations](https://github.com/MadsLorentzen/ai-job-search/discussions/78) discussion so others can find it.
|
||||
3. Run the framework update checker (`python3 tools/check_upstream_updates.py`) in your fork to check if upstream has updated any framework files and compare them with your personalized variants.
|
||||
|
||||
Market-specific skills are genuinely valuable - they just live in forks, where their maintainers can test them and their users can find them.
|
||||
|
||||
One practical warning: when you open a PR from a fork, GitHub targets this upstream repo by default, not your own - three personalized-fork PRs landed here by accident in a single week ([#155], [#162], [#165]). Check the "base repository" dropdown before publishing.
|
||||
|
||||
## Porting to another AI runtime? Forks too
|
||||
|
||||
Claude Code is the reference runtime: it is what the maintainer runs daily and what every methodology change is verified on. A parallel command tree for another runtime (Codex, Antigravity, Gemini CLI, ...) would ship untested on every change - CI cannot run those harnesses - and each accepted runtime makes the next one harder to refuse. It is the same arithmetic that keeps market-specific portals in forks.
|
||||
|
||||
What upstream maintains for other runtimes instead:
|
||||
|
||||
- The portal search skills in `.agents/skills/` use the portable Agent Skills format (`SKILL.md` per portal) and are auto-discovered by Codex and Antigravity today.
|
||||
- The root `AGENTS.md` points any agent at the canonical workflow specs and the profile entry point.
|
||||
- Framework instruction files carry `framework_version` markers, so a runtime fork can track methodology changes precisely (`python3 tools/check_upstream_updates.py`).
|
||||
|
||||
Announce your runtime fork in the pinned [Community forks & adaptations](https://github.com/MadsLorentzen/ai-job-search/discussions/78) discussion and it gets listed alongside the market adaptations. The proven shape is a thin pointer: reference the specs here instead of copying them, so upstream improvements reach your fork on rebase.
|
||||
|
||||
This is a decision, not a dogma: if cross-runtime standards mature to the point where these specs run unmodified elsewhere, or the community's center of gravity moves to runtime forks, the trade-off gets re-evaluated. Background: the architecture thread in [Community forks & adaptations](https://github.com/MadsLorentzen/ai-job-search/discussions/78).
|
||||
|
||||
## Practical notes
|
||||
|
||||
- **Portal-skill contract**: `search`/`detail` commands, `--format json|table|plain`, stderr JSON errors with exit 1, backoff on 429/5xx, zero runtime dependencies by default. See `/add-portal`'s spec and `linkedin-search` as the reference implementation.
|
||||
- **Personal-use boundaries**: portal skills that touch ToS-restricted sources carry a prominent personal-use-only warning, and CI deliberately makes no live portal requests. Don't "fix" that.
|
||||
- **LaTeX changes**: both templates must compile (`lualatex` for the CV, `xelatex` for the cover letter) and hold their exact page counts. CI smoke-checks this.
|
||||
|
||||
Questions and proposals are welcome in [Discussions](https://github.com/MadsLorentzen/ai-job-search/discussions) - an Idea thread costs nothing and can save you building the wrong thing :-)
|
||||
|
||||
[#17]: https://github.com/MadsLorentzen/ai-job-search/issues/17
|
||||
[#30]: https://github.com/MadsLorentzen/ai-job-search/issues/30
|
||||
[#31]: https://github.com/MadsLorentzen/ai-job-search/issues/31
|
||||
[#35]: https://github.com/MadsLorentzen/ai-job-search/issues/35
|
||||
[#36]: https://github.com/MadsLorentzen/ai-job-search/issues/36
|
||||
[#37]: https://github.com/MadsLorentzen/ai-job-search/issues/37
|
||||
[#39]: https://github.com/MadsLorentzen/ai-job-search/issues/39
|
||||
[#41]: https://github.com/MadsLorentzen/ai-job-search/issues/41
|
||||
[#43]: https://github.com/MadsLorentzen/ai-job-search/issues/43
|
||||
[#44]: https://github.com/MadsLorentzen/ai-job-search/issues/44
|
||||
[#49]: https://github.com/MadsLorentzen/ai-job-search/issues/49
|
||||
[#52]: https://github.com/MadsLorentzen/ai-job-search/issues/52
|
||||
[#54]: https://github.com/MadsLorentzen/ai-job-search/issues/54
|
||||
[#55]: https://github.com/MadsLorentzen/ai-job-search/issues/55
|
||||
[#56]: https://github.com/MadsLorentzen/ai-job-search/issues/56
|
||||
[#59]: https://github.com/MadsLorentzen/ai-job-search/issues/59
|
||||
[#60]: https://github.com/MadsLorentzen/ai-job-search/issues/60
|
||||
[#63]: https://github.com/MadsLorentzen/ai-job-search/issues/63
|
||||
[#64]: https://github.com/MadsLorentzen/ai-job-search/issues/64
|
||||
[#66]: https://github.com/MadsLorentzen/ai-job-search/issues/66
|
||||
[#67]: https://github.com/MadsLorentzen/ai-job-search/issues/67
|
||||
[#68]: https://github.com/MadsLorentzen/ai-job-search/issues/68
|
||||
[#72]: https://github.com/MadsLorentzen/ai-job-search/issues/72
|
||||
[#73]: https://github.com/MadsLorentzen/ai-job-search/issues/73
|
||||
[#75]: https://github.com/MadsLorentzen/ai-job-search/issues/75
|
||||
[#76]: https://github.com/MadsLorentzen/ai-job-search/issues/76
|
||||
[#155]: https://github.com/MadsLorentzen/ai-job-search/pull/155
|
||||
[#162]: https://github.com/MadsLorentzen/ai-job-search/pull/162
|
||||
[#165]: https://github.com/MadsLorentzen/ai-job-search/pull/165
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Mads Lorentzen
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,384 @@
|
||||
<p align="center">
|
||||
<img src="assets/mascot/pip_flight_loop.gif" alt="Pip, the courier bird" width="200">
|
||||
</p>
|
||||
|
||||
# AI Job Search
|
||||
|
||||
*The job search that runs on your machine.*
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/43622?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-43622" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/43622/daily" alt="MadsLorentzen%2Fai-job-search | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MadsLorentzen/ai-job-search/actions/workflows/ci.yml)
|
||||
|
||||
An AI-powered job application framework built on [Claude Code](https://claude.com/claude-code). Fork it, fill in your profile, and let Claude evaluate job postings, tailor your CV, write cover letters, and prepare you for interviews.
|
||||
|
||||
> Note: This is an independent open-source project and is not affiliated with, endorsed by, sponsored by, or maintained by Anthropic. Anthropic and Claude Code are referenced only to describe the toolchain this workflow uses.
|
||||
>
|
||||
> This project has **no affiliated cryptocurrency, token, or paid sponsorship program**. Anything claiming otherwise is unauthorized and should be treated as a scam. The only ways to support the project are the Ko-fi link below and contributing on GitHub.
|
||||
|
||||
## Does it actually work?
|
||||
|
||||
I'm a geophysicist by training. When my position was cut in late 2025, I built this framework to run my own job search - the same `/scrape`, `/apply`, and `/interview` workflow in this repo, used weekly, on my own career. I was upfront about it with every employer I spoke to, and instead of counting against me, it usually sparked a genuine technical conversation.
|
||||
|
||||
Sixty-nine tailored applications, twenty first interviews, and one signed contract later, I started as an AI engineer in June 2026. People kept asking whether this actually works. It got me hired. Now it's yours.
|
||||
|
||||
*The longer version, including the full application funnel, is on [LinkedIn](https://www.linkedin.com/in/mads-lorentzen/).*
|
||||
|
||||
<p align="center">
|
||||
<i>Did this save you a Sunday of cover-letter writing? Consider a coffee.<br>
|
||||
Did it land you the job? Maybe two.</i> ☕
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://ko-fi.com/madslorentzen">
|
||||
<img src="https://storage.ko-fi.com/cdn/kofi3.png?v=6" alt="Buy me a coffee at ko-fi.com" height="40">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## What this is
|
||||
|
||||
A structured workflow that turns Claude Code into a full-stack job application assistant. The core workflow (self-profiling, fit evaluation, and the drafter-reviewer application pipeline) is **language- and country-agnostic**. The job portal search skills are built for the Danish market (Jobindex, Jobnet, Akademikernes Jobbank, etc.), but the pattern is designed to be swapped for your local job boards.
|
||||
|
||||
```
|
||||
/setup /scrape /apply <url>
|
||||
| | |
|
||||
v v v
|
||||
Fill in Search job Evaluate fit
|
||||
your profile portals Score & recommend
|
||||
| | |
|
||||
v v v
|
||||
Profile Present matches Draft CV + Cover Letter
|
||||
files ready with fit ratings (LaTeX, tailored)
|
||||
| |
|
||||
v v
|
||||
Pick a match Reviewer agent critiques
|
||||
-> /apply -> Revise -> Final output
|
||||
```
|
||||
|
||||
The framework encodes career guidance best practices, including structured evaluation criteria, forward-looking cover letter framing, and optional salary benchmarking.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Claude Code](https://claude.com/claude-code) (CLI). Using a different agent tool (Codex, Antigravity, Gemini CLI)? Start at [`AGENTS.md`](AGENTS.md) - the portal search skills work there out of the box, and [community forks](https://github.com/MadsLorentzen/ai-job-search/discussions/78) adapt the full workflow.
|
||||
- Python 3.10+
|
||||
- [Bun](https://bun.sh) (for job search CLI tools)
|
||||
- LaTeX distribution with `lualatex` and `xelatex`: [TeX Live](https://tug.org/texlive/), [MacTeX](https://tug.org/mactex/), [TinyTeX](https://yihui.org/tinytex/), or [MiKTeX](https://miktex.org/). The CV compiles with `lualatex` (pdflatex often fails on modern MiKTeX installs with `fontawesome5` font-expansion errors); the cover letter compiles with `xelatex` because `cover.cls` requires `fontspec`. If using a minimal TeX install such as TinyTeX or BasicTeX, install the extra packages listed in [SETUP.md](SETUP.md#minimal-tex-install-tinytexbasictex).
|
||||
- Optional: `pdftotext` from [poppler](https://poppler.freedesktop.org/) (macOS: `brew install poppler`, Debian/Ubuntu: `apt install poppler-utils`, Windows: `choco install poppler`) — used by `/apply`'s ATS parseability check on the compiled CV. If missing, the check degrades gracefully to a visual keyword review.
|
||||
|
||||
## Quick start
|
||||
|
||||
> 🎥 **Prefer to see it in action first?** [The Next New Thing did a hands-on walkthrough](https://www.youtube.com/watch?v=HoVxjMNFYv4) of how the workflow is actually used, from setup to a finished application (recorded August 2026 - commands may have evolved since).
|
||||
|
||||
### 1. Fork and clone
|
||||
|
||||
```bash
|
||||
gh repo fork MadsLorentzen/ai-job-search --clone
|
||||
cd ai-job-search
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **A fork of this repo is always public** — GitHub does not allow private forks of
|
||||
> public repositories — and `/setup` (step 3 below) writes your personal data (name,
|
||||
> contact details, employment history, salary expectations) into **tracked** files.
|
||||
> If this copy is for your own job search rather than for contributing changes back,
|
||||
> use a **private repository** with this repo as `upstream` instead — the two-minute
|
||||
> recipe is in [SETUP.md section 8](SETUP.md#8-pulling-upstream-updates-into-your-fork),
|
||||
> and every update workflow works identically. Fork only to contribute.
|
||||
|
||||
### 2. Install job search tools
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$tools = @("jobbank-search", "jobdanmark-search", "jobindex-search", "jobnet-search", "linkedin-search", "freehire-search")
|
||||
foreach ($tool in $tools) {
|
||||
Push-Location ".agents/skills/$tool/cli"
|
||||
bun install
|
||||
Pop-Location
|
||||
}
|
||||
```
|
||||
|
||||
Bash / zsh / Git Bash:
|
||||
|
||||
```bash
|
||||
for tool in jobbank-search jobdanmark-search jobindex-search jobnet-search linkedin-search freehire-search; do
|
||||
(cd .agents/skills/$tool/cli && bun install)
|
||||
done
|
||||
```
|
||||
|
||||
For `linkedin-search` and `freehire-search` the install is optional: both have zero runtime dependencies and run with plain `bun`; `bun install` only pulls TypeScript dev types.
|
||||
|
||||
### 3. Set up your profile
|
||||
|
||||
```bash
|
||||
claude
|
||||
# Then inside Claude Code:
|
||||
/setup
|
||||
```
|
||||
|
||||
`/setup` offers three paths: read your `documents/` folder if you have one populated (CV PDF, LinkedIn export, diplomas, reference letters, past applications), import a single CV pasted in chat, or walk through an interview. It auto-detects what you have and asks. Documents-folder mode is idempotent and safe to re-run as you add more material; see `documents/README.md` for the layout.
|
||||
|
||||
### 4. Search for jobs
|
||||
|
||||
```bash
|
||||
/scrape
|
||||
```
|
||||
|
||||
This searches multiple job portals for positions matching your profile, deduplicates results, and presents them sorted by fit. Pick a match to run `/apply` on it directly — or, when a scrape returns more jobs than you want to eyeball, run `/rank` to batch-score them all against the fit framework and get a ranked shortlist first.
|
||||
|
||||
### 5. Apply to a job
|
||||
|
||||
```bash
|
||||
/apply https://jobindex.dk/job/1234567
|
||||
```
|
||||
|
||||
If the URL can't be fetched (some job portals block automated access), you can paste the job description directly instead:
|
||||
|
||||
```bash
|
||||
/apply <paste the full job description here>
|
||||
```
|
||||
|
||||
This runs the full workflow: evaluate fit, draft CV + cover letter, review with a second agent, revise, and present the final output.
|
||||
|
||||
Postings are treated as untrusted input (the workflow follows no instructions embedded in them and fetches no links from their body), but agentic defenses are instruction-level, not a sandbox - on an unfamiliar job board, skim what was fetched and written before you hit send. Details in [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Other commands
|
||||
|
||||
`/setup`, `/scrape`, and `/apply` form the core workflow. Ten more commands extend it once your profile is in place:
|
||||
|
||||
- **`/interview`** preps you for a scheduled interview on a tracked application. It builds a stage-specific prep pack from the application's archive (the exact posting, the CV and cover letter the interviewer actually read, feedback recorded from earlier rounds), researches the company and interviewers with a verify-before-use rule, maps likely questions to your STAR examples, and offers a mock interview following the roleplay protocol in `07-interview-prep.md`. Gaps get honest bridge answers, never invented experience.
|
||||
- **`/outcome`** records what happened to an application - interview stages, offers, rejections, silence. It archives the submitted CV, cover letter, and posting text into `documents/applications/<company>_<role>/`, keeps `outcome.md` in the format `/setup` Path A parses, and updates the tracker. It also owns the stretch before there is an outcome to record: `/outcome followup` surfaces open applications that have gone quiet (default 10 days), drafts a short channel-appropriate follow-up in your writing style using only claims from the materials you already submitted (drafts only, never sends; at most twice per application), and offers a thank-you note in the same turn an interview stage is recorded. Once a few applications resolve, it points you back to `/setup` to calibrate the fit framework from what actually got interviews.
|
||||
- **`/notion-sync`** publishes a one-way, read-only view of the pipeline into a Notion database via the official Notion MCP server (OAuth, no API keys) - one row per ranked job plus every tracked application, with a write-once briefing page per row. The repo files stay the system of record: nothing syncs back, and documents sync as filenames only. Complements `/html-report`: that is the deep offline dashboard you regenerate at your desk; this is the glanceable live view from anywhere Notion runs (desktop, web, phone).
|
||||
- **`/gmail-sync`** reads your Gmail (via the Gmail connector) for status signals on your open applications - interview invites, assessment links, offers, rejections - and proposes them as a batch for you to approve before anything is written to the tracker or `outcome.md`, citing the source email on every proposed change. Offers stop short of proposing `hired`/`offer_declined` since that's your call; conflicting or unmatched signals get flagged for a manual `/outcome` pass instead of guessed.
|
||||
- **`/rank`** bridges `/scrape` and `/apply`: it batch-scores all newly scraped postings against the fit framework (parallel agents fetch each posting and score the five evaluation dimensions) and returns a ranked shortlist with honest per-job strengths and gaps. Deal-breakers veto, deadlines get urgency flags, dead postings get marked expired. Pick a number and it hands off to the full `/apply` workflow.
|
||||
- **`/expand`** enriches your profile by scanning public sources you've already linked in it (GitHub repos, portfolio site, Kaggle, Google Scholar) and looking up syllabi for named courses and certifications. Discovered competencies are added to your profile with a source tag. Useful right after `/setup` to surface skills that documents alone don't make explicit.
|
||||
- **`/upskill`** analyzes the gap between your profile, your tracked job postings, and your ranked-but-untracked postings (`/rank`'s recorded gaps in `seen_jobs.json`) — or a single posting via `/upskill <URL>`. Produces a prioritized heatmap of skill gaps and a learning plan with web-searched study resources and time estimates. Useful for career planning between applications.
|
||||
- **`/html-report`** generates a self-contained HTML dashboard from `job_search_tracker.csv` and the application archives — stat cards, status/sector/channel/funnel charts (inline SVG, no external dependencies), and a filterable applications table. Opens directly in a browser, fully offline. Re-run it any time after `/apply` or `/outcome` adds new entries.
|
||||
- **`/add-template`** registers your own CV or cover letter template (LaTeX, Typst, or another toolchain) in place of the stock ones. It captures the template's instructions (source extension, compile command, fonts, style rules, page limit), runs a mandatory test compile, and wires the template into `/apply`. See [Custom templates](#custom-templates) below.
|
||||
- **`/add-portal`** generates a job-portal search skill for a job board in your market. It investigates the portal (search URL pattern, result structure, access rules), scaffolds the CLI skill from the same structure as the shipped ones, and test-runs a live query before registering. See [Job search tools](#job-search-tools) below.
|
||||
|
||||
`/reset` is also available, see [Starting over](#starting-over) below.
|
||||
|
||||
## File structure
|
||||
|
||||
```
|
||||
ai-job-search/
|
||||
├── CLAUDE.md # Main candidate profile + workflow rules
|
||||
├── .claude/
|
||||
│ ├── commands/
|
||||
│ │ ├── apply.md # /apply workflow (drafter-reviewer)
|
||||
│ │ ├── setup.md # /setup onboarding (documents folder, CV import, or interview)
|
||||
│ │ ├── expand.md # /expand competency enrichment from documents and online presence
|
||||
│ │ ├── add-template.md # /add-template register custom templates (LaTeX, Typst, ...)
|
||||
│ │ ├── add-portal.md # /add-portal generate a job-portal search skill for your market
|
||||
│ │ ├── rank.md # /rank triage scraped jobs into a ranked shortlist
|
||||
│ │ ├── outcome.md # /outcome record application results, archive materials
|
||||
│ │ ├── gmail-sync.md # /gmail-sync auto-detect application status from Gmail
|
||||
│ │ ├── interview.md # /interview stage-specific prep pack + mock interview
|
||||
│ │ ├── html-report.md # /html-report generate application tracker dashboard
|
||||
│ │ ├── notion-sync.md # /notion-sync one-way pipeline view in a Notion database
|
||||
│ │ └── reset.md # /reset wipe profile data or documents folder
|
||||
│ ├── skills/
|
||||
│ │ ├── job-application-assistant/ # Core application skill
|
||||
│ │ │ ├── SKILL.md # Skill definition
|
||||
│ │ │ ├── 01-candidate-profile.md # Your education, experience, skills
|
||||
│ │ │ ├── 02-behavioral-profile.md# PI/DISC/personality assessment
|
||||
│ │ │ ├── 03-writing-style.md # Tone, structure, do's and don'ts
|
||||
│ │ │ ├── 04-job-evaluation.md # Scoring framework for job fit
|
||||
│ │ │ ├── 05-cv-templates.md # LaTeX CV structure + tailoring rules
|
||||
│ │ │ ├── 06-cover-letter-templates.md # LaTeX cover letter templates
|
||||
│ │ │ └── 07-interview-prep.md # STAR examples + interview framework
|
||||
│ │ ├── job-scraper/ # Job search orchestration
|
||||
│ │ └── upskill/ # /upskill skill gap analysis and learning plan
|
||||
│ └── settings.json # Claude Code permissions (shared, scoped)
|
||||
├── .agents/skills/ # Job portal CLI tools
|
||||
│ ├── jobbank-search/ # Akademikernes Jobbank (Denmark)
|
||||
│ ├── jobdanmark-search/ # Jobdanmark.dk (Denmark)
|
||||
│ ├── jobindex-search/ # Jobindex.dk (Denmark)
|
||||
│ ├── jobnet-search/ # Jobnet.dk (Denmark, government portal)
|
||||
│ ├── linkedin-search/ # LinkedIn public job listings (country-agnostic)
|
||||
│ └── freehire-search/ # freehire.me tech job aggregator (multi-market, REST API)
|
||||
├── cv/
|
||||
│ └── main_example.tex # moderncv LaTeX template
|
||||
├── cover_letters/
|
||||
│ ├── cover.cls # Custom cover letter LaTeX class
|
||||
│ ├── cover_example.tex # Example cover letter (structural reference + CI smoke test)
|
||||
│ └── OpenFonts/ # Lato + Raleway fonts
|
||||
├── templates/ # Custom templates registered via /add-template
|
||||
│ └── README.md # Folder layout instructions
|
||||
├── documents/ # Career source materials for /setup Path A and /expand
|
||||
│ ├── README.md # Folder layout instructions
|
||||
│ ├── cv/ # Master CV (PDF or .tex)
|
||||
│ ├── linkedin/ # LinkedIn profile export (PDF)
|
||||
│ ├── diplomas/ # Degree certificates and transcripts
|
||||
│ ├── references/ # Reference letters
|
||||
│ └── applications/ # Past application records (<company>_<role>/)
|
||||
├── .github/workflows/ci.yml # CI: LaTeX smoke compiles, skill lint, CLI typechecks
|
||||
├── salary_lookup.py # Salary benchmarking tool (BYO data)
|
||||
├── tools/
|
||||
│ ├── convert_salary_excel.py # Convert salary Excel to JSON
|
||||
│ ├── lint_skills.py # CI lint for skills, commands, settings.json
|
||||
│ ├── security_guards.py # CI guards: permission allowlist, gitignore rules, manifests
|
||||
│ └── README_SALARY_TOOL.md # Salary tool setup instructions
|
||||
├── job_scraper/ # Scraper state (seen jobs, results)
|
||||
├── gmail_sync/ # /gmail-sync state (processed message IDs, last sync date)
|
||||
├── upskill/ # /upskill report output (markdown reports per run)
|
||||
├── job_search_tracker.csv # Application tracking spreadsheet
|
||||
└── SETUP.md # Detailed setup guide
|
||||
```
|
||||
|
||||
## How `/apply` works
|
||||
|
||||
The `/apply` command runs a **drafter-reviewer workflow** with mandatory PDF compilation:
|
||||
|
||||
1. **Parse** the job posting (URL or text)
|
||||
2. **Evaluate fit** against your profile (skills, experience, culture, location, career alignment)
|
||||
3. **Draft** a tailored CV and cover letter in LaTeX
|
||||
4. **Spawn a reviewer agent** that researches the company and critiques the drafts
|
||||
5. **Revise** based on the reviewer's feedback
|
||||
6. **Compile and inspect** both PDFs: lualatex for the CV, xelatex for the cover letter. Claude reads the rendered pages and iterates on the LaTeX until the CV is exactly 2 pages with no orphaned entry titles, and the cover letter is exactly 1 page with the signature visible and fonts consistent.
|
||||
7. **ATS-check the CV**: extract the PDF's text layer (`pdftotext`, optional dependency) and verify it the way an ATS parser sees it — contact details present as literal text, no garbled glyphs, sane reading order — then score the posting's keyword coverage against the extraction. Keywords the profile genuinely supports get added; genuine gaps stay visible, never stuffed.
|
||||
8. **Present** the final output with a verification checklist
|
||||
|
||||
All claims in the CV and cover letter are verified against your actual profile. The system never fabricates skills or experience.
|
||||
|
||||
### What makes this workflow different
|
||||
|
||||
- **PDF verification loop.** Most LaTeX-resume templates produce "looks fine in the .tex" output that breaks in the PDF: job titles orphan to the next page, cover letters spill onto page 2, bullet fonts silently fall back to the body font. The `/apply` command compiles and visually inspects every PDF and applies targeted fixes (`\needspace`, `\enlargethispage`, font-matching wrappers for list items) until the layout is clean. This runs automatically on every application.
|
||||
- **ATS verification on the PDF text layer.** An ATS reads the PDF's embedded text, not the rendered page — and LaTeX can silently produce PDFs whose text extracts as garbage (icon glyphs where the email should be, interleaved lines from multi-column layouts). `/apply` extracts the compiled CV's text layer with `pdftotext` and verifies contact details, reading order, and the posting's keyword coverage against what a parser actually sees. Honesty rule enforced: a keyword the profile doesn't support is acknowledged as a gap, never stuffed in.
|
||||
- **Relevance-weighted CV cutting.** When a CV overflows 2 pages, the workflow does not cut mechanically from the "oldest" section. It scores each candidate line by (a) relevance to the target posting, (b) uniqueness in the document, and (c) whether the cover letter depends on it, and cuts the lowest-total-score line first. An older-role bullet that hits posting keywords survives ahead of a recent-role bullet that does not.
|
||||
- **Drafter-reviewer separation.** The drafter writes; a second Claude agent, spawned with a fresh context, researches the company and critiques the drafts. The drafter then revises. This catches missed keywords, weak framing, and generic language that a single pass often leaves in.
|
||||
- **Token-efficient reviewer dispatch.** The reviewer agent receives drafts inline rather than re-reading them, and the verification checklist runs once at the end of the workflow rather than being duplicated by both agents. Note: the new compile-and-inspect step in Step 5 spends some of those savings on PDF rendering and layout iteration — the workflow trades some end-to-end token cost for a real reduction in broken PDFs reaching the user.
|
||||
|
||||
## Customization
|
||||
|
||||
### Which files to edit manually
|
||||
|
||||
If you prefer editing files directly instead of using `/setup`:
|
||||
|
||||
| File | What to change |
|
||||
|------|---------------|
|
||||
| `CLAUDE.md` | Your full profile (name, education, experience, skills, goals) |
|
||||
| `01-candidate-profile.md` | Structured version of your CV data |
|
||||
| `02-behavioral-profile.md` | Your behavioral assessment or self-assessment |
|
||||
| `04-job-evaluation.md` | Skill match areas, career goals, motivation filters |
|
||||
| `05-cv-templates.md` | Profile statement templates for different role types |
|
||||
| `07-interview-prep.md` | Your STAR examples from actual experience |
|
||||
| `search-queries.md` | Job search queries for your skills and location |
|
||||
|
||||
### Updating your search queries
|
||||
|
||||
As your priorities evolve, you can reconfigure just the job search without re-running the full profile setup:
|
||||
|
||||
```
|
||||
/setup --section search
|
||||
```
|
||||
|
||||
This re-runs the search configuration interview: which roles to target, which skills to search for, which locations, and which portals. It also suggests role types you may not have considered based on your profile.
|
||||
|
||||
### Custom templates
|
||||
|
||||
The CV uses [moderncv](https://ctan.org/pkg/moderncv) (banking style). The cover letter uses a custom `cover.cls` with Lato/Raleway fonts. Both are LaTeX — the reference engine this repo ships and maintains.
|
||||
|
||||
To use your own template instead — LaTeX, [Typst](https://typst.app/), or any other toolchain that compiles to PDF from the command line — run:
|
||||
|
||||
```
|
||||
/add-template
|
||||
```
|
||||
|
||||
Point it at your source file (a `.tex` file plus any `.cls`/`.sty` files or bundled fonts; a `.typ` file plus any local packages; or an equivalent for another toolchain). The command interviews you for the template's instructions — source extension, compile command, fonts and where they live, style rules to preserve, hard page limit — stores everything under `templates/`, runs a mandatory test compile, and activates the template so `/apply` drafts and compiles from it. Templates are stored with `[PLACEHOLDER]` tokens instead of personal data, so they're safe to commit and share.
|
||||
|
||||
- `/add-template --list` shows registered templates
|
||||
- `/add-template --use <name>` switches between them
|
||||
- `/add-template --use default` reverts to the stock moderncv / cover.cls templates
|
||||
|
||||
If you prefer doing it by hand, the manual route still works: update the guidance in `05-cv-templates.md` and `06-cover-letter-templates.md`.
|
||||
|
||||
### Job search tools
|
||||
|
||||
The four Danish CLI tools in `.agents/skills/` (Jobbank, Jobdanmark, Jobindex, Jobnet) demonstrate the pattern for building a job-portal integration for a specific market. If you're in a different country, run:
|
||||
|
||||
```
|
||||
/add-portal
|
||||
```
|
||||
|
||||
Give it your local job board's URL. The command investigates the portal (search-URL pattern, result-page structure, robots.txt/access rules), scaffolds a CLI skill with the same structure, commands, and output contract as the shipped ones, and test-runs a live query before registering anything. Auth-walled portals are declined, and portals with restrictive terms get a prominent personal-use-only warning in the generated skill. The generated skill is market-specific and lives in your fork; the generator itself is the universal part.
|
||||
|
||||
Maintaining a fork adapted to your market or language? Add it to the [Community forks & adaptations](https://github.com/MadsLorentzen/ai-job-search/discussions/78) thread so others can find it.
|
||||
|
||||
For **country-agnostic** starting points outside Denmark, the repo ships two portal skills alongside the Danish demos:
|
||||
|
||||
- **`linkedin-search`** — built on LinkedIn's public, unauthenticated `jobs-guest` endpoints. Field-agnostic, **zero runtime dependencies** (runs with just `bun`), and takes the search location as an explicit flag, so it works for any market out of the box (`-l "Berlin, Germany"`, `-l "Mumbai, Maharashtra, India"`, `-l "Remote"`, …). Intended for **personal use only** — automated access is against LinkedIn's Terms of Service, so keep volume low. See `.agents/skills/linkedin-search/SKILL.md`.
|
||||
- **`freehire-search`** — queries the [freehire.me](https://freehire.me) aggregator's public REST API (JSON, no API key). Tech-focused (software, data, engineering, DevOps, remote), multi-market via facet flags (`--region`, `--country`, `--remote`), and **zero runtime dependencies**. Unlike the HTML-scraping Danish portals, results come back structured (skills, seniority, category). The backend is MIT-licensed and [self-hostable](https://github.com/strelov1/freehire) — point `FREEHIRE_API_URL` at your own instance if you prefer. See `.agents/skills/freehire-search/SKILL.md`.
|
||||
|
||||
### Extending the framework: portals, templates, criteria - and borrowing from other forks
|
||||
|
||||
Everything above adds up to an extension model, so here it is stated plainly. The framework has three extension points, and none of them require touching upstream:
|
||||
|
||||
1. **Portal skills** - the module system for job boards. Every `*-search` skill is a self-contained folder under `.agents/skills/` with the same contract (a `search`/`detail` CLI, `--format json|table|plain` output, an `enabled:` flag in its `SKILL.md`, its own tests). `/scrape` auto-discovers any installed skill that follows the contract - nothing to register, nothing to wire up. `/add-portal` generates new ones; the [community portal index](https://github.com/MadsLorentzen/ai-job-search/discussions/78) catalogs the ones other forks have built.
|
||||
2. **Document templates** - `/add-template` registers any CV or cover-letter toolchain that compiles to PDF from the command line, LaTeX or otherwise.
|
||||
3. **Evaluation criteria** - deal-breakers and preferences in your profile are free-form, and the evaluation rubric scores against whatever you put there. "Strong parental-leave terms", "minimum salary X per my union's scale", "no on-call" - each is one profile line, no code, and it carries real weight in `/rank` and `/apply` fit evaluations. Language is the one deal-breaker type with dedicated, structured handling: `/setup` captures every language you work in and your level (asked directly, or inferred from your CV/LinkedIn export) into a `Languages` table, and the Language Gate (`04-job-evaluation.md`) hard-rejects a posting that requires a language you haven't declared at all, while flagging - not auto-rejecting - one that asks for a higher level than you declared in a language you do work in, so a borderline case (a strict "fluent" bar against your own B1/B2, say) gets your judgment instead of a silent drop.
|
||||
|
||||
**Borrowing a portal skill from another fork** is the intended way to get a board that upstream doesn't ship: find it in the [portal index](https://github.com/MadsLorentzen/ai-job-search/discussions/78), open that fork, and copy the one folder into your own `.agents/skills/`. Before you run it:
|
||||
|
||||
- **Read the code.** All of it - these CLIs run pre-approved on your machine (`.claude/settings.json` allowlists them) against your career data. Check that the only network calls go to the job board it claims to search, that `package.json` has no `dependencies` and no lifecycle scripts (`postinstall` etc.), and that nothing reads or writes outside its own folder.
|
||||
- **Run its tests offline** (`bun test` in the skill's `cli/` directory) - a well-built skill's tests pass with no network access.
|
||||
- Check the `enabled:` flag and the skill's own ToS notes.
|
||||
|
||||
The copy step is manual on purpose. Your settings already allow installed portal skills to run without asking each time - so an installer that fetched them from third-party repos for you would skip the one check that matters: you, reading the code first. There isn't one, and that's a security decision rather than a missing feature.
|
||||
|
||||
Market-specific *data sources* (a national salary database, local award-rate tables) follow the same pattern as portals: they belong in a market fork, shared via [#78](https://github.com/MadsLorentzen/ai-job-search/discussions/78), not upstream.
|
||||
|
||||
### Salary benchmarking
|
||||
|
||||
The salary tool works with any salary data you provide (union statistics, Glassdoor exports, personal research, etc.). See `tools/README_SALARY_TOOL.md` for the expected format and setup. If you don't have salary data, the salary step is simply skipped.
|
||||
|
||||
### Starting over
|
||||
|
||||
To wipe your profile data and start fresh:
|
||||
|
||||
```
|
||||
/reset profile # clears skill files, preserves framework rules
|
||||
/reset documents # deletes files from documents/ folder
|
||||
/reset all # both
|
||||
```
|
||||
|
||||
`/reset` shows exactly what will be deleted and requires you to type `RESET` to confirm. Nothing is deleted until you do.
|
||||
|
||||
### Staying up to date
|
||||
|
||||
Upstream moves fast. Rather than pulling raw `master` and hoping, update your fork to a tagged [release](../../releases) - a vetted checkpoint described in [CHANGELOG.md](CHANGELOG.md). `python3 tools/check_upstream_updates.py` previews exactly which of your personalized files an update touches before you merge, and `python3 tools/upstream_triage.py` sorts the commits you're behind into "worth reviewing" vs "probably skip" (a weekly workflow can post this to a rolling issue). Full walkthrough in [SETUP.md, section 8](SETUP.md#8-pulling-upstream-updates-into-your-fork).
|
||||
|
||||
## Tips for better results
|
||||
|
||||
### Profile depth matters
|
||||
|
||||
The single biggest factor in output quality is how much detail you put into your profile. A thin profile produces generic applications; a detailed one enables genuinely tailored results.
|
||||
|
||||
- **Role descriptions:** Don't just list job titles. Describe what you actually did in each position: specific projects, tools used, responsibilities, and measurable achievements. The more material you provide, the more precisely the system can reframe your experience for different roles.
|
||||
- **Skills in context:** Instead of listing "Python" or "project management," describe how and where you applied them. "Built ML pipelines for customer churn prediction in Python using scikit-learn" gives the system far more to work with than "Python, machine learning."
|
||||
- **All onboarding paths work:** Whether you point `/setup` at your `documents/` folder, paste a single CV, or walk through the interview, the principle is the same: richer input produces sharper output.
|
||||
|
||||
### Career path discovery
|
||||
|
||||
The framework supports two distinct modes of job searching:
|
||||
|
||||
- **Explicit targeting:** You know which roles or sectors you want. The system helps refine and prioritize based on fit.
|
||||
- **Latent opportunity discovery:** By analyzing your full history (not just job titles, but the actual work you did), the system can surface career paths you haven't considered. Transferable skills that map to unexpected industries, patterns in what you enjoyed or excelled at, or emerging roles that combine your domain expertise with new technology.
|
||||
|
||||
To get the most from this, invest time during `/setup` in describing not just your experience, but what energized you, what drained you, and what you'd want more of. This context directly shapes how the system evaluates fit and which roles it surfaces during `/scrape`.
|
||||
|
||||
## Contributing
|
||||
|
||||
Thinking about a PR? Read [CONTRIBUTING.md](CONTRIBUTING.md) first - it explains what gets merged, what lives in forks, and why.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- [Mikkel Krogholm](https://github.com/mikkelkrogsholm) ([skills repo](https://github.com/mikkelkrogsholm/skills)) for the job search CLI skills
|
||||
- Built with [Claude Code](https://claude.com/claude-code) by [Anthropic](https://anthropic.com)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report security findings privately via **[GitHub private vulnerability reporting](https://github.com/MadsLorentzen/ai-job-search/security/advisories/new)** rather than a public issue. You will get a response within a few days, credit in the fix unless you prefer otherwise, and public disclosure coordinated with the patch.
|
||||
|
||||
If the private form is unavailable, open a public issue that describes the *class* of problem without a working recipe, and note that you have details to share privately.
|
||||
|
||||
## Threat model, honestly stated
|
||||
|
||||
This is an agentic workflow: an LLM with file access reads untrusted web content (job postings) alongside your personal data (CV, profile, application history). That combination is the main risk surface, and it cannot be fully eliminated - only narrowed. What the framework does about it:
|
||||
|
||||
- **Untrusted-input rules**: `/apply` and `/rank` treat posting text as data, never instructions - agents are told not to follow directions embedded in postings and not to fetch URLs found inside posting text (the user-supplied posting URL is the one exception). Reviewer research starts from the company identity the user confirmed, never from links in the posting body.
|
||||
- **Permission allowlist**: `.claude/settings.json` pre-approves only the specific commands the workflow needs; the `security-guards` CI job fails any PR that widens it, adds package-manifest lifecycle scripts, or weakens the personal-data gitignore rules. Note the allowlist governs Bash commands - the model's native WebFetch/WebSearch tools are outside its reach, which is exactly why the instruction-level rules above exist.
|
||||
- **Personal data boundaries**: your populated profile, tracker, salary data, and application archive are gitignored; documents never leave the machine by design (`/notion-sync` syncs filenames only; nothing uploads document content anywhere).
|
||||
|
||||
Instruction-level defenses raise the bar; they are not a sandbox. If you run this workflow against job boards you do not trust at all, review what the agent fetched and wrote before sending anything out.
|
||||
|
||||
## Scope notes
|
||||
|
||||
- Portal CLI skills make live requests only when you run them; CI never does.
|
||||
- Community fork skills listed in the [forks index](https://github.com/MadsLorentzen/ai-job-search/discussions/78) are **not** covered by this policy - review the code you copy, as the index itself says.
|
||||
@@ -0,0 +1,342 @@
|
||||
# Setup Guide
|
||||
|
||||
Step-by-step instructions for getting the AI Job Search framework running.
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
### Claude Code
|
||||
|
||||
Install Claude Code (Anthropic's CLI for Claude):
|
||||
|
||||
```bash
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
```
|
||||
|
||||
You'll need an Anthropic API key or a Claude Pro/Team subscription. See the [Claude Code docs](https://docs.anthropic.com/en/docs/claude-code) for details.
|
||||
|
||||
### Python
|
||||
|
||||
Python 3.10+ is required for the salary lookup tool. Check with:
|
||||
|
||||
```bash
|
||||
python3 --version
|
||||
```
|
||||
|
||||
On Windows, `py --version` is often the most reliable check. If your system exposes Python as `python` instead of `python3`, use `python` in the commands below.
|
||||
|
||||
### Bun (for job search tools)
|
||||
|
||||
The job portal CLIs (four Danish portals plus the country-agnostic `linkedin-search` and `freehire-search` tools) are written in TypeScript and run with Bun.
|
||||
|
||||
- macOS/Linux:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
```
|
||||
|
||||
- Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -c "irm https://bun.sh/install.ps1 | iex"
|
||||
```
|
||||
|
||||
If you prefer a package manager, `winget install Oven-sh.Bun` also works on Windows.
|
||||
|
||||
### LaTeX (for compiling CVs and cover letters)
|
||||
|
||||
Install a LaTeX distribution to compile the generated `.tex` files to PDF:
|
||||
|
||||
- **Windows:** [MiKTeX](https://miktex.org/download)
|
||||
- **macOS:** [MacTeX](https://tug.org/mactex/)
|
||||
- **Linux:** `sudo apt install texlive-full` or `sudo dnf install texlive-scheme-full`
|
||||
|
||||
The CV compiles with `lualatex` (pdflatex often fails on modern MiKTeX installs with `fontawesome5` font-expansion errors). The cover letter compiles with `xelatex` because `cover.cls` requires `fontspec` for its custom Lato/Raleway fonts.
|
||||
|
||||
#### Minimal TeX install: TinyTeX/BasicTeX
|
||||
|
||||
Full TeX distributions work out of the box, but minimal distributions need a few extra packages before the stock templates compile.
|
||||
|
||||
On macOS, a user-level TinyTeX install avoids a system-wide installer and does not require `sudo`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://yihui.org/tinytex/install-bin-unix.sh -o /tmp/tinytex-install-bin-unix.sh
|
||||
sh /tmp/tinytex-install-bin-unix.sh /tmp --no-path
|
||||
export PATH="$HOME/Library/TinyTeX/bin/universal-darwin:$PATH"
|
||||
```
|
||||
|
||||
Then install the template dependencies:
|
||||
|
||||
```bash
|
||||
tlmgr install \
|
||||
moderncv fontawesome5 fontawesome6 academicons import luatexbase pgf \
|
||||
titlesec textpos xltxtra xunicode cite realscripts needspace
|
||||
```
|
||||
|
||||
For BasicTeX/MacTeX, make sure the TeX binary directory is on `PATH` first (for example via `/Library/TeX/texbin`), then run the same `tlmgr install ...` command.
|
||||
|
||||
Quick smoke tests after setup:
|
||||
|
||||
```bash
|
||||
cd cv && lualatex -interaction=nonstopmode -halt-on-error main_example.tex && cd ..
|
||||
|
||||
SMOKE_DIR="$(mktemp -d /tmp/ai-job-cover-smoke.XXXXXX)"
|
||||
cp -R cover_letters/cover.cls cover_letters/OpenFonts "$SMOKE_DIR/"
|
||||
cat >"$SMOKE_DIR/cover_smoke.tex" <<'EOF'
|
||||
\documentclass[]{cover}
|
||||
\begin{document}
|
||||
\namesection{Test}{Candidate}{test@example.com}
|
||||
\companyname{Example Company}
|
||||
\companyaddress{123 Hiring Street\\Example City}
|
||||
\currentdate{\today}
|
||||
\lettercontent{Dear Hiring Manager,}
|
||||
\lettercontent{This smoke test verifies that xelatex can load cover.cls and the bundled fonts.}
|
||||
\closing{Sincerely,}
|
||||
\signature{Test Candidate}
|
||||
\end{document}
|
||||
EOF
|
||||
(cd "$SMOKE_DIR" && xelatex -interaction=nonstopmode -halt-on-error cover_smoke.tex)
|
||||
```
|
||||
|
||||
#### Windows: Basic MiKTeX
|
||||
|
||||
The full MiKTeX installer bundles every CTAN package and works out of the box, but the smaller [Basic MiKTeX](https://miktex.org/download) installer (`basic-miktex-*.exe`) only ships a minimal package set and needs a couple of one-time settings before the stock templates compile.
|
||||
|
||||
By default, MiKTeX installs missing packages on demand but pops up a GUI prompt for each one — which blocks non-interactive terminals (including Claude Code's Bash tool). Turn that into a silent auto-install instead:
|
||||
|
||||
```powershell
|
||||
initexmf --admin --set-config-value=[MPM]AutoInstall=1
|
||||
initexmf --set-config-value=[MPM]AutoInstall=1
|
||||
```
|
||||
|
||||
(Run the first line from an elevated/Admin PowerShell if you installed MiKTeX for all users; the second line covers a per-user install. Only one will apply depending on how you installed it — running both is harmless.)
|
||||
|
||||
If you'd rather not rely on on-the-fly installs at all (for example, for a fully offline compile later), pre-install the same package set the macOS TinyTeX section above lists, using MiKTeX's package manager:
|
||||
|
||||
```powershell
|
||||
mpm --admin --install=moderncv --install=fontawesome5 --install=fontawesome6 --install=academicons --install=import --install=luatexbase --install=pgf --install=titlesec --install=textpos --install=xltxtra --install=xunicode --install=cite --install=realscripts --install=needspace
|
||||
```
|
||||
|
||||
Drop `--admin` if MiKTeX is installed for the current user only. If a package name doesn't resolve, `mpm --find=<name>` searches the repository for the correct name.
|
||||
|
||||
Quick smoke tests after setup (PowerShell):
|
||||
|
||||
```powershell
|
||||
Set-Location cv; lualatex -interaction=nonstopmode -halt-on-error main_example.tex; Set-Location ..
|
||||
|
||||
$SmokeDir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP "ai-job-cover-smoke-$(Get-Random)")
|
||||
Copy-Item cover_letters\cover.cls, cover_letters\OpenFonts -Destination $SmokeDir -Recurse
|
||||
@'
|
||||
\documentclass[]{cover}
|
||||
\begin{document}
|
||||
\namesection{Test}{Candidate}{test@example.com}
|
||||
\companyname{Example Company}
|
||||
\companyaddress{123 Hiring Street\\Example City}
|
||||
\currentdate{\today}
|
||||
\lettercontent{Dear Hiring Manager,}
|
||||
\lettercontent{This smoke test verifies that xelatex can load cover.cls and the bundled fonts.}
|
||||
\closing{Sincerely,}
|
||||
\signature{Test Candidate}
|
||||
\end{document}
|
||||
'@ | Set-Content (Join-Path $SmokeDir "cover_smoke.tex")
|
||||
Push-Location $SmokeDir; xelatex -interaction=nonstopmode -halt-on-error cover_smoke.tex; Pop-Location
|
||||
```
|
||||
|
||||
### Optional: pdftotext (for the ATS check)
|
||||
|
||||
`/apply` runs an ATS parseability check on the compiled CV: it extracts the PDF's text layer and verifies contact details, reading order, and keyword coverage the way an applicant-tracking system sees them. This uses `pdftotext` from [poppler](https://poppler.freedesktop.org/), which is not part of TeX distributions:
|
||||
|
||||
- **macOS:** `brew install poppler`
|
||||
- **Debian/Ubuntu:** `sudo apt install poppler-utils`
|
||||
- **Windows:** `choco install poppler`
|
||||
|
||||
If `pdftotext` is missing, `/apply` skips the mechanical check with a warning and falls back to a visual keyword review — everything else works normally.
|
||||
|
||||
## 2. Fork and clone
|
||||
|
||||
```bash
|
||||
gh repo fork MadsLorentzen/ai-job-search --clone
|
||||
cd ai-job-search
|
||||
```
|
||||
|
||||
Or manually: fork on GitHub, then clone your fork.
|
||||
|
||||
> **Before you go further: forks are public.** GitHub cannot make a fork of a public
|
||||
> repository private, and `/setup` (section 6) writes your personal data into **tracked**
|
||||
> files — pushing those commits to a fork publishes them. If this copy is for your own
|
||||
> job search rather than for contributing, prefer a **private repository** with this repo
|
||||
> as `upstream`: see section 8, step 1 for the exact commands and why committing your
|
||||
> personalization there is still the right move. Everything else in this guide works
|
||||
> identically either way.
|
||||
|
||||
## 3. Install job search CLI dependencies
|
||||
Run these from the repository root.
|
||||
|
||||
- PowerShell:
|
||||
|
||||
```powershell
|
||||
$tools = @("jobbank-search", "jobdanmark-search", "jobindex-search", "jobnet-search", "linkedin-search", "freehire-search")
|
||||
foreach ($tool in $tools) {
|
||||
Push-Location ".agents/skills/$tool/cli"
|
||||
bun install
|
||||
Pop-Location
|
||||
}
|
||||
```
|
||||
|
||||
- Bash / zsh / Git Bash:
|
||||
```bash
|
||||
for tool in jobbank-search jobdanmark-search jobindex-search jobnet-search linkedin-search freehire-search; do
|
||||
(cd .agents/skills/$tool/cli && bun install)
|
||||
done
|
||||
```
|
||||
|
||||
For `linkedin-search` and `freehire-search` the install is optional: both have zero runtime dependencies and run with plain `bun`; `bun install` only pulls TypeScript dev types.
|
||||
|
||||
If you're outside Denmark, you can generate an equivalent search skill for your local job board with `/add-portal` — it scaffolds the same CLI structure for any public portal and test-runs a live query before registering. See the "Job search tools" section in the README.
|
||||
|
||||
## 4. Run the setup interview
|
||||
|
||||
Start Claude Code in the repository:
|
||||
|
||||
```bash
|
||||
claude
|
||||
```
|
||||
|
||||
Then run the onboarding:
|
||||
|
||||
```
|
||||
/setup
|
||||
```
|
||||
|
||||
Claude will offer three paths:
|
||||
|
||||
- **Path A (documents folder):** Add your CV, LinkedIn export, diplomas, references, or past applications under `documents/`. Claude reads and cross-references them before proposing profile updates. This is best when you have several source files.
|
||||
- **Path B (single CV import):** Share one CV/resume by mentioning the file with `@` or pasting the text. Claude extracts it and asks follow-up questions for anything missing.
|
||||
- **Path C (interview mode):** Answer structured interview questions section by section.
|
||||
|
||||
All three paths produce the same result: fully populated profile files.
|
||||
|
||||
### What gets populated
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `CLAUDE.md` | Your full candidate profile |
|
||||
| `01-candidate-profile.md` | Structured education, experience, skills |
|
||||
| `02-behavioral-profile.md` | Behavioral assessment |
|
||||
| `04-job-evaluation.md` | Personalized skill match areas and career goals |
|
||||
| `05-cv-templates.md` | Profile statement templates for your background |
|
||||
| `07-interview-prep.md` | STAR examples from your experience |
|
||||
| `cv/main_example.tex` | Your LaTeX CV with actual details |
|
||||
| `search-queries.md` | Job search queries for `/scrape` |
|
||||
|
||||
### Re-running setup
|
||||
|
||||
You can update specific sections later:
|
||||
|
||||
```
|
||||
/setup --section skills
|
||||
/setup --section experience
|
||||
/setup --section search
|
||||
```
|
||||
|
||||
The `--section search` option is especially useful as your priorities evolve. It re-runs the search configuration interview and suggests role types you may not have considered based on your full profile.
|
||||
|
||||
## 5. Optional: Set up salary benchmarking
|
||||
|
||||
If you have salary data (from a union, salary survey, Glassdoor, or personal research):
|
||||
|
||||
1. **Option A:** Create `salary_data.json` manually in the repo root (see `tools/README_SALARY_TOOL.md` for the format)
|
||||
2. **Option B:** Convert from Excel:
|
||||
```bash
|
||||
pip install openpyxl
|
||||
python3 tools/convert_salary_excel.py path/to/salary-data.xlsx --source "My Salary Data 2025"
|
||||
```
|
||||
|
||||
This creates `salary_data.json` which the `/apply` workflow uses for salary benchmarking. If you skip this step, salary lookup is simply omitted.
|
||||
|
||||
## 6. Test the workflow
|
||||
|
||||
Find a job posting you're interested in, then:
|
||||
|
||||
```
|
||||
/apply https://jobindex.dk/job/1234567
|
||||
```
|
||||
|
||||
Or paste the job description directly:
|
||||
|
||||
```
|
||||
/apply [paste job posting text here]
|
||||
```
|
||||
|
||||
Claude will:
|
||||
1. Evaluate the fit against your profile
|
||||
2. Ask if you want to proceed
|
||||
3. Draft a tailored CV and cover letter
|
||||
4. Have a reviewer agent critique the drafts
|
||||
5. Revise and present the final output
|
||||
|
||||
## 7. Compile your documents
|
||||
|
||||
After `/apply` creates the LaTeX files:
|
||||
|
||||
```bash
|
||||
# Bash / zsh / Git Bash
|
||||
cd cv && lualatex main_<company>_<role>.tex && cd ..
|
||||
cd cover_letters && xelatex cover_<company>_<role>.tex && cd ..
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell
|
||||
Set-Location cv; lualatex main_<company>_<role>.tex; Set-Location ..
|
||||
Set-Location cover_letters; xelatex cover_<company>_<role>.tex; Set-Location ..
|
||||
```
|
||||
|
||||
These commands apply to the stock templates (moderncv CV, `cover.cls` cover letter). If you'd rather use your own LaTeX template, run `/add-template` — it captures the template's compile engine, fonts, style rules, and page limit, test-compiles it, and wires it into `/apply`. See the "LaTeX templates" section in the README.
|
||||
|
||||
## 8. Pulling upstream updates into your fork
|
||||
|
||||
Upstream keeps improving the methodology files your fork has personalized, so plan for updates from day one:
|
||||
|
||||
**Prefer releases over raw `master`.** Tagged [releases](../../releases) are vetted checkpoints, each described in [CHANGELOG.md](CHANGELOG.md). Updating to a tag pulls a stable, documented state instead of whatever `master` happens to be mid-review. Fetch tags with `git fetch upstream --tags` and merge a release (for example `git merge v1.0.0`) when you want stability; pull `master` directly only when you specifically want the latest unreleased changes. The steps below apply either way - substitute the release tag for `upstream/master` where you see it.
|
||||
|
||||
1. **Commit your personalization - but know where those commits land.** `/setup` edits CLAUDE.md and the profile skill files in place; those edits are *yours*, and committing them is what lets updates merge cleanly. But a GitHub **fork of this repo is public** - forks of public repositories cannot be made private - so anything you commit *and push to a fork* is visible to anyone. If you want your profile in a remote at all, don't push it to a fork: create a **private** repository, push there, and add this repo as the `upstream` remote (`git remote add upstream https://github.com/MadsLorentzen/ai-job-search.git`) to keep receiving updates. Committing locally without pushing is also fine. The genuinely sensitive files (tracker, salary data, `documents/`, application archives) are gitignored and never enter git either way. An uncommitted working tree is the most common reason `git pull` refuses to merge at all (`Your local changes ... would be overwritten`).
|
||||
2. **Preview what changed before pulling:**
|
||||
```bash
|
||||
git remote add upstream https://github.com/MadsLorentzen/ai-job-search.git # first time only, if you cloned your own fork
|
||||
git fetch upstream # or origin, if you cloned the template directly
|
||||
python3 tools/check_upstream_updates.py
|
||||
```
|
||||
It compares the `framework_version` markers in your framework files against upstream and lists exactly which methodology files changed, with the diff command for each.
|
||||
|
||||
Two tools answer two different questions, and it's worth running both:
|
||||
- **`check_upstream_updates.py`** — *which of my personalized files changed?* It reads the `framework_version` stamp on each methodology file, so it flags exactly the customized files a release touched.
|
||||
- **`upstream_triage.py`** — *which upstream commits deserve my attention?* It walks the commits you're behind and sorts them into "worth reviewing" vs "probably skip", dropping anything you've already cherry-picked (matched by `git patch-id`, so ported work falls off with no bookkeeping), commits that only touch files your fork removed, and SHAs you've listed in `.github/upstream-wontport.txt`. It's report-only — it prints ready-to-run `git cherry-pick` lines but never merges, pushes, or opens a PR, because on a fork "applies cleanly" isn't "correct".
|
||||
|
||||
```bash
|
||||
python3 tools/upstream_triage.py --remote upstream
|
||||
```
|
||||
|
||||
Forks also inherit a `.github/workflows/upstream-watch.yml` that runs this weekly and writes the result into a single rolling issue (it no-ops on the upstream template itself, and stays disabled on a fork until you enable Actions).
|
||||
3. **Merge normally.** `git merge upstream/master` (or `git pull`) three-way-merges upstream's edits around your personalization; because methodology edits rarely touch the lines `/setup` filled in, most updates land cleanly. A conflict in a personalized file is a *feature*, not a failure — it means upstream changed methodology in a section you customized, and the version marker plus its changelog commit tell you why. Resolve by keeping your data and adopting the methodology change around it.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "salary_data.json not found"
|
||||
This is expected if you haven't set up salary benchmarking. The `/apply` workflow skips this step automatically.
|
||||
|
||||
### Job search CLI tools not working
|
||||
Make sure Bun is installed and you ran `bun install` in each CLI directory. The tools require network access to fetch job listings.
|
||||
|
||||
### LaTeX compilation errors
|
||||
- CV: uses `lualatex` (pdflatex often fails on modern MiKTeX with `fontawesome5` font-expansion errors; lualatex handles the same sources cleanly)
|
||||
- Cover letter: uses `xelatex` (for custom fonts in `OpenFonts/fonts/`)
|
||||
- Make sure your LaTeX distribution includes the `moderncv` package
|
||||
|
||||
### Fonts not found in cover letter
|
||||
The cover letter template expects fonts in `cover_letters/OpenFonts/fonts/`. Make sure this directory exists and contains the Lato and Raleway font files.
|
||||
|
||||
### Stale `.claude/settings.local.json` from an older clone
|
||||
Shared Claude Code permissions now live in `.claude/settings.json` (scoped to `bun run`, `python salary_lookup.py`, and `python3 salary_lookup.py`). Earlier versions of this repo committed a broader `.claude/settings.local.json` that pre-approved `Bash(curl:*)`, `Bash(python:*)` and `Bash(bun:*)`. If you cloned before that change, git leaves the old file behind in your working copy, and its permissions still apply on top of `settings.json`. Delete it (or trim it to your own personal overrides):
|
||||
|
||||
```bash
|
||||
rm .claude/settings.local.json
|
||||
```
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Executable
+96
@@ -0,0 +1,96 @@
|
||||
% Intro Options
|
||||
\ProvidesClass{cover}[2024/04/30 Cover letter class]
|
||||
\NeedsTeXFormat{LaTeX2e}
|
||||
\DeclareOption{print}{\def\@cv@print{}}
|
||||
\DeclareOption*{%
|
||||
\PassOptionsToClass{\CurrentOption}{article}
|
||||
}
|
||||
\ProcessOptions\relax
|
||||
\LoadClass{article}
|
||||
|
||||
% Package Imports
|
||||
\usepackage[hmargin=2.3cm, vmargin=2.3cm]{geometry}
|
||||
\usepackage[hidelinks]{hyperref}
|
||||
\usepackage[usenames,dvipsnames]{xcolor}
|
||||
\usepackage{titlesec}
|
||||
\usepackage[absolute]{textpos}
|
||||
\usepackage{fontspec,xltxtra,xunicode}
|
||||
|
||||
% Publications
|
||||
\usepackage{cite}
|
||||
\renewcommand\refname{\vskip -1.5cm}
|
||||
|
||||
% Color definitions
|
||||
\definecolor{date}{HTML}{666666}
|
||||
\definecolor{primary}{HTML}{2b2b2b}
|
||||
\definecolor{headings}{HTML}{6A6A6A}
|
||||
\definecolor{subheadings}{HTML}{333333}
|
||||
|
||||
% Set main fonts
|
||||
\setmainfont[Color=primary, Path = OpenFonts/fonts/lato/,BoldItalicFont=Lato-RegIta,BoldFont=Lato-Reg,ItalicFont=Lato-LigIta]{Lato-Lig}
|
||||
\setsansfont[Scale=MatchLowercase,Mapping=tex-text, Path = OpenFonts/fonts/raleway/]{Raleway-ExtraLight}
|
||||
|
||||
% Date command
|
||||
\setlength{\TPHorizModule}{1mm}
|
||||
\setlength{\TPVertModule}{1mm}
|
||||
\newcommand{\lastupdated}{\begin{textblock}{60}(155,5)
|
||||
\color{date}\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-ExtraLight}\fontsize{8pt}{10pt}\selectfont
|
||||
Last Updated on \today
|
||||
\end{textblock}}
|
||||
|
||||
% Name command
|
||||
\newcommand{\namesection}[3]{
|
||||
\centering{
|
||||
\fontsize{40pt}{60pt}
|
||||
\fontspec[Path = OpenFonts/fonts/lato/]{Lato-Hai}\selectfont #1
|
||||
\fontspec[Path = OpenFonts/fonts/lato/]{Lato-Lig}\selectfont #2
|
||||
} \\[5pt]
|
||||
\centering{
|
||||
\color{headings}
|
||||
\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{14pt}\selectfont #3}
|
||||
\noindent\makebox[\linewidth]{\color{headings}\rule{\paperwidth}{0.0pt}}
|
||||
\vspace{0pt}
|
||||
}
|
||||
|
||||
% Section seperators
|
||||
\titlespacing{\section}{0pt}{0pt}{0pt}
|
||||
\titlespacing{\subsection}{0pt}{0pt}{0pt}
|
||||
\newcommand{\sectionsep}{\vspace{8pt}}
|
||||
|
||||
% Headings command
|
||||
\titleformat{\section}{\color{headings}
|
||||
\scshape\fontspec[Path = OpenFonts/fonts/lato/]{Lato-Lig}\fontsize{16pt}{24pt}\selectfont \raggedright\uppercase}{}{0em}{}
|
||||
|
||||
% Subeadings command
|
||||
\titleformat{\subsection}{
|
||||
\color{subheadings}\fontspec[Path = OpenFonts/fonts/lato/]{Lato-Bol}\fontsize{12pt}{12pt}\selectfont\bfseries\uppercase}{}{0em}{}
|
||||
|
||||
\newcommand{\runsubsection}[1]{
|
||||
\color{subheadings}\fontspec[Path = OpenFonts/fonts/lato/]{Lato-Bol}\fontsize{12pt}{12pt}\selectfont\bfseries\uppercase {#1} \normalfont}
|
||||
|
||||
% Descriptors command
|
||||
\newcommand{\descript}[1]{
|
||||
\color{subheadings}\raggedright\scshape\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\} \normalfont}
|
||||
|
||||
% Location command
|
||||
\newcommand{\location}[1]{
|
||||
\color{headings}\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{10pt}{12pt}\selectfont {#1\\} \normalfont}
|
||||
|
||||
% Bullet Lists with fewer gaps command
|
||||
\newenvironment{tightemize}{
|
||||
\vspace{-\topsep}\begin{itemize}\itemsep1pt \parskip0pt \parsep0pt}
|
||||
{\end{itemize}\vspace{-\topsep}}
|
||||
|
||||
% Cover Letter
|
||||
\newcommand{\companyname}[1]{\raggedright\fontspec[Path = OpenFonts/fonts/lato/]{Lato-Bol}\fontsize{12pt}{14pt}\selectfont {#1 \\} \normalfont}
|
||||
|
||||
\newcommand{\companyaddress}[1]{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\}\mbox{}\\\mbox{}\\ \normalfont}
|
||||
|
||||
\newcommand{\currentdate}[1]{\raggedleft\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\} \normalfont}
|
||||
|
||||
% Letter content command
|
||||
\newcommand{\lettercontent}[1]{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\}\mbox{}\\ \normalfont}
|
||||
|
||||
\newcommand{\closing}[1]{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\}\mbox{}\\\mbox{}\\ \normalfont}
|
||||
|
||||
\newcommand{\signature}[1]{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont {#1 \\} \normalfont}
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||
% Cover Letter - [Company], [Role]
|
||||
%
|
||||
% Example cover letter with placeholder content. This is the structural
|
||||
% reference for /apply (see 06-cover-letter-templates.md) and the CI smoke
|
||||
% test for cover.cls: it must always compile with xelatex to exactly 1 page.
|
||||
% It demonstrates the correct itemize pattern: the list sits OUTSIDE
|
||||
% \lettercontent{} (whose trailing \\ errors on \end{itemize}) and is
|
||||
% wrapped in Raleway-Medium so the bullet font matches the body.
|
||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||
|
||||
\documentclass[]{cover}
|
||||
\usepackage{fancyhdr}
|
||||
|
||||
\pagestyle{fancy}
|
||||
\fancyhf{}
|
||||
|
||||
\rfoot{Page \thepage \hspace{0pt}}
|
||||
\thispagestyle{empty}
|
||||
\renewcommand{\headrulewidth}{0pt}
|
||||
\begin{document}
|
||||
|
||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||
% TITLE NAME
|
||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||
\namesection{}{\Huge{[YOUR NAME]}}{ \href{mailto:your.email@example.com}{your.email@example.com} | [+XX XXXXXXXXXX] | \urlstyle{same}\href{https://www.linkedin.com/in/yourprofile}{LinkedIn}
|
||||
}
|
||||
|
||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||
% MAIN COVER LETTER CONTENT
|
||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||
|
||||
\currentdate{\today}
|
||||
\lettercontent{Dear [Hiring Manager / Team],}
|
||||
|
||||
\lettercontent{[Opening paragraph: name the role and where you found it, state your strongest connection to it in one sentence, and preview why you are a fit. Keep it to 2--3 sentences.]}
|
||||
|
||||
\lettercontent{[Body paragraph: your most relevant experience, framed toward the tasks in the posting. Follow with 3--5 concrete bullets:]}
|
||||
|
||||
{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont
|
||||
\begin{itemize}
|
||||
\item \textbf{[Achievement 1]:} [concrete result with a number where possible]
|
||||
\item \textbf{[Achievement 2]:} [skill or project mapped to a posting requirement]
|
||||
\item \textbf{[Achievement 3]:} [evidence for a nice-to-have requirement]
|
||||
\end{itemize}\par}
|
||||
\vspace{6pt}
|
||||
|
||||
\lettercontent{[Connection paragraph: why this company specifically. Reference a verified specific: a product, a stated priority, a team. Never generic.]}
|
||||
|
||||
\lettercontent{[Personal fit paragraph: behavioral strengths and what you bring to the team, 2--3 sentences.]}
|
||||
|
||||
\lettercontent{I look forward to hearing from you.}
|
||||
|
||||
\begin{flushright}
|
||||
% Note: no trailing \\ inside \closing{} - cover.cls appends its own \\, and a
|
||||
% doubled break triggers "There's no line here to end."
|
||||
\closing{Kind regards,}
|
||||
|
||||
\signature{[YOUR NAME]}
|
||||
\end{flushright}
|
||||
\end{document}
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
%% Example CV - Prad Nukala
|
||||
%% Comprehensive overview of all competencies, experience, and achievements.
|
||||
%% Use as a master reference when tailoring role-specific CVs.
|
||||
%%
|
||||
%% Populated by /setup on 2026-08-20. Compile with: lualatex main_example.tex
|
||||
%% (pdflatex often fails on modern MiKTeX with fontawesome5 font-expansion errors.)
|
||||
|
||||
\documentclass[11pt,a4paper,sans]{moderncv}
|
||||
\moderncvstyle{banking}
|
||||
\moderncvcolor{blue}
|
||||
|
||||
% Force the name and section headings to render in moderncv blue (color1).
|
||||
% Default banking leaves them black: moderncvstylebanking.sty's \colorlet
|
||||
% copies (not aliases) the pre-scheme accent colour, so the name colours are
|
||||
% frozen before \moderncvcolor runs. Re-let them after. \namefont is the hook
|
||||
% every name-style macro routes through, so this also works on moderncv 2.3.1
|
||||
% (Debian/Ubuntu apt), which has no \firstnamestyle/\lastnamestyle at all.
|
||||
\renewcommand*{\namefont}{\fontsize{34}{36}\bfseries\upshape}
|
||||
\colorlet{firstnamecolor}{color1}
|
||||
\colorlet{lastnamecolor}{color1}
|
||||
\colorlet{namecolor}{color1}
|
||||
\renewcommand*{\sectionstyle}[1]{{\sectionfont\color{color1}#1}}
|
||||
|
||||
\usepackage[utf8]{inputenc}
|
||||
% moderncv loads hyperref itself in an \AtEndPreamble hook, so \hypersetup
|
||||
% must go in an \AtEndPreamble of our own: on moderncv < 2.4 a top-level
|
||||
% \usepackage{hyperref} clashes with the class's own
|
||||
% \RequirePackage[unicode]{hyperref}. From 2.4.0 the class passes its options
|
||||
% through \PassOptionsToPackage instead, which is what removes that clash.
|
||||
\AtEndPreamble{\hypersetup{
|
||||
colorlinks=true,
|
||||
linkcolor=blue,
|
||||
filecolor=magenta,
|
||||
urlcolor=blue,
|
||||
pdftitle={Prad Nukala - CV},
|
||||
% Keep pdfpagemode=UseNone: this block runs after moderncv's own
|
||||
% \AtEndPreamble (moderncv.cls sets pdfpagemode there), so a FullScreen
|
||||
% value here would win and open every CV in fullscreen presentation mode.
|
||||
pdfpagemode=UseNone,
|
||||
}}
|
||||
\usepackage[scale=0.80]{geometry}
|
||||
\usepackage{import}
|
||||
\usepackage{needspace}
|
||||
|
||||
% personal data
|
||||
\name{Prad}{Nukala}
|
||||
\address{New York, NY, USA}{}{}
|
||||
\phone[mobile]{+1 (646) 508-7789}
|
||||
\email{prnk28@gmail.com}
|
||||
\extrainfo{\href{https://www.linkedin.com/in/pradn}{LinkedIn}, \href{https://github.com/prdlk}{GitHub}, \href{https://prad.nu}{prad.nu}}
|
||||
|
||||
\begin{document}
|
||||
|
||||
\makecvtitle
|
||||
|
||||
% ============================================================
|
||||
% PROFILE STATEMENT
|
||||
% ============================================================
|
||||
|
||||
\vspace{6pt}
|
||||
\small{Backend and protocol engineer with 10+ years shipping software and five years owning a production Layer-1 end to end. Co-founded Sonr and raised \$4.7M to build a Cosmos SDK chain in Go with Protobuf/gRPC services, EVM and CosmWasm compatibility, and IBC cross-chain transfer; operated its 4-node testnet at 99\% uptime on Kubernetes and Terraform. Wrote the supporting cryptography myself (MPC/DKG, threshold signatures, constant-time curve arithmetic) and led a 6-engineer team shipping a React Native E2EE messenger. W3C and DIF working-group member. Uses Claude Code daily for AI-assisted development.}
|
||||
|
||||
% ============================================================
|
||||
% CORE COMPETENCIES
|
||||
% ============================================================
|
||||
|
||||
\section{Core Competencies}
|
||||
\vspace{1pt}
|
||||
\begin{itemize}
|
||||
|
||||
\item \textbf{Backend \& Protocol Engineering}: Go, TypeScript, Protobuf/gRPC, REST; Cosmos SDK, CometBFT, IBC, CosmWasm, EVM; modular state machines, mempool/AnteHandler design, validator operations.
|
||||
|
||||
\item \textbf{Applied Cryptography \& Identity}: MPC/DKG, threshold ECDSA/Ed25519, FROST, Bulletproofs, constant-time secp256k1/Ed25519/BLS12-381; WebAuthn/passkeys, W3C DIDs, UCAN, DWN; Go-to-WASM signing enclaves.
|
||||
|
||||
\item \textbf{Infrastructure \& DevOps}: Kubernetes, Docker, Terraform, Ansible, Google Cloud, AWS (Lambda, SageMaker), Cloudflare Workers (Durable Objects, KV), GitHub Actions, GoReleaser, KMS.
|
||||
|
||||
\item \textbf{Mobile \& Full-Stack}: React Native, Flutter/Dart, Swift, Kotlin, React.js, PostgreSQL/SQLite; consumer apps at 1M+ downloads; Fastlane CI/CD to App Store and Play Store.
|
||||
|
||||
\item \textbf{Leadership \& Communication}: Founded and ran a 7-FTE company plus two agencies; $\$$4.7M raised; token-economics design with Prysm Group; onboarded 120+ developers through workshops; MIT Sloan guest lecturer; Fast Company byline. AI-assisted workflows with Claude Code.
|
||||
|
||||
\end{itemize}
|
||||
|
||||
% ============================================================
|
||||
% PROFESSIONAL EXPERIENCE
|
||||
% ============================================================
|
||||
|
||||
\section{Professional Experience}
|
||||
\vspace{3pt}
|
||||
\begin{itemize}
|
||||
|
||||
% --- Most Recent Role ---
|
||||
\item{\cventry{2021-Present}{Co-Founder \& CEO}{Sonr}{New York, NY, USA}{}{\vspace{1pt}
|
||||
\begin{itemize}
|
||||
\item {Founded the company and secured \$4.7M to build a peer-to-peer identity and asset-management system on DIDs, WebAuthn, and IPFS; directed 7 full-time staff and two specialist agencies.}
|
||||
\item {Architected a custom Layer-1 blockchain in Go on the Cosmos SDK: modular state machine, Protobuf/gRPC services, REST gateways, EVM and CosmWasm compatibility, IBC transfers. Top contributor (692 commits) on \href{https://github.com/sonr-io/sonr}{sonr-io/sonr} (642 stars).}
|
||||
\item {Engineered ``Gasless Onboarding'': custom AnteHandlers validate WebAuthn passkeys at the mempool level so new users never see seed phrases or fees.}
|
||||
\item {Operated the testnet across 4 validator nodes (SF, NY, VA, Asia) with KMS-secured profiles on Kubernetes, Terraform, and Ansible, sustaining 99\% uptime.}
|
||||
\item {Implemented and shipped the \href{https://github.com/sonr-io/crypto}{core cryptography library}: MPC/DKG, threshold signatures, Bulletproofs, constant-time curves, AES-GCM, Argon2id, with automated security tests and benchmarks.}
|
||||
\item {Led a 6-engineer team shipping Beam, a cross-platform React Native E2EE messenger over a customized Matrix homeserver with passkey-wallet login replacing email/phone auth.}
|
||||
\item {Drove open-source growth to the 4th-highest global GitHub contributions (Oct 2021 - Oct 2022) and onboarded 120+ first-time blockchain developers at DevNet workshops (PennApps, CalHacks, ETHGlobal HackFS).}
|
||||
\item {Led the 2026 pivot to B2B white-label wallet infrastructure through hands-on customer discovery.}
|
||||
\end{itemize}}}
|
||||
|
||||
\vspace{3pt}
|
||||
|
||||
% --- Previous Role ---
|
||||
\needspace{5\baselineskip}
|
||||
\item{\cventry{Aug 2020 - May 2021}{Mobile App Developer}{Leidos}{Herndon, VA, USA}{}{\vspace{1pt}
|
||||
\begin{itemize}
|
||||
\item {Shipped Flutter/Dart features for the U.S. Customs and Border Protection Mobile Passport app, used by millions of travelers, within federal security, privacy, and accessibility standards.}
|
||||
\end{itemize}}}
|
||||
|
||||
\vspace{3pt}
|
||||
|
||||
\needspace{5\baselineskip}
|
||||
\item{\cventry{May 2019 - Aug 2019}{Data Science Intern}{Unissant}{Herndon, VA, USA}{}{\vspace{1pt}
|
||||
\begin{itemize}
|
||||
\item {Deployed an ML fraud-detection model on AWS SageMaker over healthcare data and a React front end on AWS Lambda to query it in real time; delivered as a consulting POC.}
|
||||
\end{itemize}}}
|
||||
|
||||
\vspace{3pt}
|
||||
|
||||
\needspace{5\baselineskip}
|
||||
\item{\cventry{May 2018 - Aug 2018}{Software Engineering Intern}{Hobsons}{Arlington, VA, USA}{}{\vspace{1pt}
|
||||
\begin{itemize}
|
||||
\item {Built a Java/Spring Boot sync-monitoring service and AngularJS dashboard whose self-serve tooling cut sales QA response times from days to minutes.}
|
||||
\end{itemize}}}
|
||||
|
||||
\vspace{3pt}
|
||||
|
||||
% --- Earlier Role ---
|
||||
\needspace{5\baselineskip}
|
||||
\item{\cventry{2015-2019}{iOS Developer (Founder)}{BriteTwist}{Ashburn, VA, USA}{}{\vspace{1pt}
|
||||
\begin{itemize}
|
||||
\item {Founded an independent app studio and shipped three consumer apps to 1M+ total downloads: \href{https://github.com/prdlk/spacebolt}{Space Bolt} (15,000 DAU, 850k downloads), \href{https://github.com/prdlk/voluntree}{VolunTree} (1,250 MAU), and \href{https://github.com/prdlk/yue}{Yue} (shipped to iOS and Android in 26 hours).}
|
||||
\item {Authored three technical articles on architecture and game development with 120k+ total reads.}
|
||||
\end{itemize}}}
|
||||
|
||||
\end{itemize}
|
||||
|
||||
% ============================================================
|
||||
% EDUCATION
|
||||
% ============================================================
|
||||
|
||||
\section{Education}
|
||||
\vspace{1pt}
|
||||
\begin{itemize}
|
||||
|
||||
\item{\cventry{2016-2020}{B.S. in Applied Mathematics}{Virginia Commonwealth University}{Richmond, VA, USA}{}{\vspace{1pt}
|
||||
Linear algebra, number theory, topological data analysis. Five MLH hackathons, including PennApps XIX (\href{https://devpost.com/software/swiper-no-mauling}{YOLOv3 livestock-protection system}) and HackHarvard 2017 (\href{https://github.com/anthonyanader/timeboARd}{ARKit whiteboard capture}).
|
||||
}}
|
||||
|
||||
\end{itemize}
|
||||
|
||||
% ============================================================
|
||||
% LANGUAGES
|
||||
% ============================================================
|
||||
|
||||
\section{Languages}
|
||||
\vspace{1pt}
|
||||
\begin{itemize}
|
||||
\item {English (native), Telugu.}
|
||||
\end{itemize}
|
||||
|
||||
% ============================================================
|
||||
% PUBLICATIONS
|
||||
% ============================================================
|
||||
|
||||
\section{Publications and Talks}
|
||||
\vspace{1pt}
|
||||
\begin{itemize}
|
||||
\item {Nukala, P. (2022). What Web3 can learn from the Mac and the iPhone. \textit{Fast Company}. \href{https://www.fastcompany.com/90814899/web3-lessons-mac-iphone}{Link}}
|
||||
\item {Nukala, P. (2019). A Deep Dive into Flutter, Parts 1-2. \textit{Medium, Flutter Community}. \href{https://medium.com/flutter-community/deep-dive-in-flutter-b0b827440a49}{Link}}
|
||||
\item {Building in the decentralized web with Sonr. Workshop, ETHGlobal HackFS (2022). \href{https://www.youtube.com/watch?v=zy6Xkh9Mvcs}{Video}}
|
||||
\end{itemize}
|
||||
|
||||
% ============================================================
|
||||
% HONORS AND AWARDS
|
||||
% ============================================================
|
||||
|
||||
\section{Honors and Affiliations}
|
||||
\vspace{1pt}
|
||||
\begin{itemize}
|
||||
\item{\textbf{Guest Lecturer, Crypto Finance (15.492)} -- MIT Sloan School of Management (2022).}
|
||||
\item{\textbf{4th-highest global GitHub contributions} -- Oct 2021 to Oct 2022.}
|
||||
\item{\textbf{W3C Working Group member} (DID, WebAuthn, WASM); \textbf{DIF Working Group member} (UCAN, DWN).}
|
||||
\end{itemize}
|
||||
|
||||
% ============================================================
|
||||
% REFERENCES
|
||||
% ============================================================
|
||||
|
||||
\section{References}
|
||||
\vspace{1pt}
|
||||
\begin{itemize}
|
||||
\item{Available upon request.}
|
||||
\end{itemize}
|
||||
|
||||
\end{document}
|
||||
Executable
+200
@@ -0,0 +1,200 @@
|
||||
# Documents Folder
|
||||
|
||||
This folder holds your actual career documents. The `/setup` command reads everything here and uses it to populate the candidate skill files under `.claude/skills/job-application-assistant/`. It is safe to re-run `/setup` as you add new documents — it merges intelligently and will never overwrite existing content without asking you first.
|
||||
|
||||
---
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
documents/
|
||||
├── cv/ # Your CV files (PDF or LaTeX)
|
||||
├── linkedin/ # LinkedIn profile export (PDF)
|
||||
├── diplomas/ # Degree certificates and transcripts
|
||||
├── references/ # Reference letters
|
||||
├── postings/ # Raw job posting text, pasted manually for pages Claude can't fetch
|
||||
│ └── <Company> - <Job Title>.txt # Filename = company + job title, content = full posting text
|
||||
├── applications/ # Past job applications
|
||||
│ └── <company>_<role>/
|
||||
│ ├── job_posting.md # The original job posting (written by /apply, or pasted)
|
||||
│ ├── cover_letter.tex # The cover letter you submitted
|
||||
│ ├── cv_draft.tex # The CV variant you submitted
|
||||
│ └── outcome.md # Result + notes (fill in after hearing back)
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## cv/
|
||||
|
||||
Your master CV — the most complete, unedited version of your professional record.
|
||||
|
||||
**Supported formats:** `.pdf`, `.tex`
|
||||
|
||||
**What `/setup` extracts:**
|
||||
- Work experience (titles, companies, dates, bullet points)
|
||||
- Education (degrees, institutions, dates, thesis topics)
|
||||
- Technical skills
|
||||
- Awards and publications
|
||||
- Contact information
|
||||
|
||||
**Naming:** Any filename works. If multiple files are present, `/setup` reads all of them and cross-references for consistency.
|
||||
|
||||
**Tip:** Keep your most comprehensive CV here (not a tailored variant). The skill files are the canonical source — tailored CVs are generated per application by `/apply`.
|
||||
|
||||
---
|
||||
|
||||
## linkedin/
|
||||
|
||||
Your LinkedIn profile exported as a PDF.
|
||||
|
||||
**How to export:** On LinkedIn, go to your profile → More → Save to PDF. This exports a structured summary of your profile.
|
||||
|
||||
**Supported formats:** `.pdf`
|
||||
|
||||
**What `/setup` extracts:**
|
||||
- Work experience and dates (cross-referenced against your CV)
|
||||
- Skills and endorsements
|
||||
- Education
|
||||
- Certifications and licenses
|
||||
- Volunteer work
|
||||
- Publications
|
||||
- About/summary section (used to infer behavioral profile additions)
|
||||
- Recommendations received (may enrich reference context)
|
||||
|
||||
**Naming:** Any filename works. Only one LinkedIn export is expected; if multiple are present, `/setup` uses the most recently modified one.
|
||||
|
||||
---
|
||||
|
||||
## diplomas/
|
||||
|
||||
Degree certificates, transcripts, and any official qualifications.
|
||||
|
||||
**Supported formats:** `.pdf`
|
||||
|
||||
**What `/setup` extracts:**
|
||||
- Degree titles and official names (used to verify education entries)
|
||||
- Graduation dates
|
||||
- Grades or distinctions (if visible)
|
||||
- Institution names (official spelling)
|
||||
|
||||
**Naming:** Use descriptive names, e.g. `msc_physics_ucph_2025.pdf`, `bsc_physics_ucph_2016.pdf`. Naming does not affect parsing.
|
||||
|
||||
---
|
||||
|
||||
## references/
|
||||
|
||||
Reference letters from former managers, supervisors, or collaborators.
|
||||
|
||||
**Supported formats:** `.pdf`, `.txt`, `.md`
|
||||
|
||||
**What `/setup` extracts:**
|
||||
- Referee name, title, and organization
|
||||
- Specific quotes and assessments (added to the references section of `01-candidate-profile.md`)
|
||||
- Competency language used by referees (adds behavioral signal to `02-behavioral-profile.md`)
|
||||
|
||||
**Naming:** Use the referee's name, e.g. `reference_ole_frandsen.pdf`.
|
||||
|
||||
---
|
||||
|
||||
## postings/
|
||||
|
||||
A drop folder for raw job posting text when Claude can't fetch a page directly (bot-blocked ATS platforms like Lever, Greenhouse behind Cloudflare, JS-heavy SPAs that return empty content, etc.). You open the posting yourself and paste the full text into a `.txt` file here.
|
||||
|
||||
**Naming:** `<Company> - <Job Title>.txt`, e.g. `RYZ Labs - Front End Engineer - React.js.txt`. Content is the full posting text, pasted as-is. Including the company keeps the drop folder collision-free when two postings share a title, and gives `/apply` the company name for free.
|
||||
|
||||
**Workflow:** Drop the file, then tell Claude in the conversation — it isn't watched automatically. Once a posting has been evaluated or applied to, it can be deleted from here or left as a record; it's a scratch inbox, not an archive (use `applications/<company>_<role>/job_posting.md` for that once you actually apply).
|
||||
|
||||
**Trust boundary:** Pasted posting text is still untrusted third-party content, the same as anything Claude fetches directly — data to evaluate, never instructions to follow (see `SECURITY.md`'s untrusted-input rules). Pasting it by hand doesn't change that.
|
||||
|
||||
---
|
||||
|
||||
## applications/
|
||||
|
||||
A record of past job applications. Each subfolder is one application.
|
||||
|
||||
You can maintain these folders by hand, or let the **`/outcome`** command do it: it records progress updates and final results conversationally, archives the submitted drafts and, if `/apply` has not already written it, the posting text, keeps `outcome.md` in the format below, and updates `job_search_tracker.csv` in the same step.
|
||||
|
||||
**Subfolder naming:** `<company>_<role>` — lowercase, underscores for spaces.
|
||||
Every character that is not a letter, digit or underscore is dropped (so `Novo Nordisk A/S`
|
||||
becomes `novo_nordisk_as`), runs of underscores collapse to one, and leading and trailing
|
||||
underscores are trimmed. If the derived name is empty, stop and ask the user for a company or
|
||||
role containing at least one letter or digit; do not create a file or directory. Every non-empty
|
||||
result is therefore a single path component whatever the posting contains.
|
||||
|
||||
Examples:
|
||||
```
|
||||
applications/
|
||||
├── acme_ml_engineer/
|
||||
├── bigcorp_software_engineer/
|
||||
└── consultco_ai_consultant/
|
||||
```
|
||||
|
||||
### Files within each application folder
|
||||
|
||||
**`job_posting.md`** — The full job posting text, written by `/apply`, or paste it here. Used by `/setup` to infer which skills and role types you have targeted, and to calibrate `04-job-evaluation.md`.
|
||||
|
||||
**`cover_letter.tex`** — The cover letter you actually submitted. Used to extract writing style patterns and structure for `06-cover-letter-templates.md`.
|
||||
|
||||
**`cv_draft.tex`** — The CV variant you submitted. Used to extract profile statement styles for `05-cv-templates.md`.
|
||||
|
||||
**`outcome.md`** — Fill this in after the application resolves. Format:
|
||||
|
||||
```markdown
|
||||
# Outcome: <Company> — <Role>
|
||||
|
||||
**Status:** in_progress | hired | offer_declined | rejected | no_response | interview_only
|
||||
|
||||
**Date resolved:** YYYY-MM-DD
|
||||
|
||||
## Interview stages reached
|
||||
- [ ] Phone screen
|
||||
- [ ] Technical interview
|
||||
- [ ] Case interview
|
||||
- [ ] Final round
|
||||
- [ ] Offer received
|
||||
|
||||
## Notes
|
||||
What happened? What feedback did you receive (if any)?
|
||||
What would you do differently?
|
||||
Any signal about what they valued or didn't?
|
||||
```
|
||||
|
||||
`in_progress` marks an application that is still open (used by `/outcome` for interview-stage updates before a resolution). `/setup`'s calibration draws conclusions only from applications with a final status.
|
||||
|
||||
Application folders may also contain **`interview_prep_<stage>.md`** files written by `/interview` (one per interview stage, kept as history). `/setup` reads only the four files named above and ignores these.
|
||||
|
||||
**What `/setup` learns from outcome.md:**
|
||||
- Which role types and companies have led to interviews (signals strong fit areas)
|
||||
- Which applications did not progress (informs the experience match calibration in `04-job-evaluation.md`)
|
||||
- Interview feedback, if you recorded it, can surface new STAR candidates
|
||||
|
||||
---
|
||||
|
||||
## File Format Notes
|
||||
|
||||
| Format | Readable by `/setup` | Notes |
|
||||
|--------|--------------------------|-------|
|
||||
| `.pdf` | Yes | Parsed directly with the Read tool |
|
||||
| `.tex` | Yes | LaTeX source — structure and content both readable |
|
||||
| `.md` | Yes | Plain text |
|
||||
| `.txt` | Yes | Plain text |
|
||||
| `.docx` | No | Convert to PDF before placing here |
|
||||
| `.png` / `.jpg` | No | Scanned documents won't be parsed — use text PDFs |
|
||||
|
||||
---
|
||||
|
||||
## Re-running `/setup`
|
||||
|
||||
The command is designed to be re-run as your document collection grows. Each run:
|
||||
|
||||
1. Reads the current state of all skill files
|
||||
2. Compares extracted document content against what's already there
|
||||
3. Only proposes changes for content that is genuinely new or conflicting
|
||||
4. Never silently overwrites — conflicts are shown explicitly for your decision
|
||||
|
||||
**When to re-run:**
|
||||
- After adding a new LinkedIn export
|
||||
- After adding reference letters
|
||||
- After recording outcomes for completed applications
|
||||
- After updating your master CV
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
# Custom Templates
|
||||
|
||||
This folder holds user-registered templates (LaTeX, Typst, or any other toolchain with a declared compile command), managed by the `/add-template` command. The framework works out of the box with its stock templates (moderncv for CVs, `cover.cls` for cover letters) — this folder only gets content when you register your own.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
templates/
|
||||
├── cv/
|
||||
│ └── <template-name>/
|
||||
│ ├── template.<ext> # Profile-agnostic skeleton ([PLACEHOLDER] tokens), e.g. template.tex or template.typ
|
||||
│ ├── TEMPLATE.md # Manifest: source extension, compile command, fonts, page limit, style rules, pitfalls
|
||||
│ ├── *.cls / *.sty # Custom class/style files, or Typst packages (if the template needs them)
|
||||
│ └── fonts/ # Bundled font files (if not using system fonts)
|
||||
└── cover_letters/
|
||||
└── <template-name>/
|
||||
└── (same layout)
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
- `/add-template` interviews you for the template's instructions (source extension, compile command, fonts, style rules, page limit), stores the files here, and runs a mandatory test compile before registering anything.
|
||||
- Activating a template adds a managed block to `05-cv-templates.md` or `06-cover-letter-templates.md`, which is what `/apply` reads when drafting and compiling — no other wiring needed.
|
||||
- `/add-template --list` shows registered templates; `/add-template --use <name>` switches; `/add-template --use default` reverts to the stock templates.
|
||||
|
||||
Templates are stored with `[PLACEHOLDER]` tokens instead of personal data, so they are safe to commit and share.
|
||||
Reference in new issue
Block a user