On a fork without an 'upstream' remote, the checker silently fell back to 'origin' (the fork itself) and still printed '[OK] All framework files are up to date with upstream!', a false positive: the fork is always up to date with itself, so upstream updates were never reported. This is exactly the setup CONTRIBUTING.md recommends for forks.
Now, when the fallback remote does not point at the ai-job-search template repo, the script warns that the comparison is fork-vs-self and prints the command to add the template as a remote. The final OK line now names the ref it actually compared against.
Tests (new tests/test_check_upstream_updates.py, three scenarios) fail on master and pass with the fix.
Two personal-data ignore rules existed in .gitignore but not in
REQUIRED_IGNORE_RULES, so a change weakening either would have passed CI:
cover_letters/Cover_*.* (the uppercase naming variant /apply recognizes)
and cv/*.txt (ATS text extractions of tailored CVs).
Also: regression tests pinning #252's ragged-row bounds fix in
convert_salary_excel.py (mutation-verified), and removal of the vestigial
cover_letters/OpenFonts/cover.cls, which since #252's rename ambiguously
declared the same class as the real cover.cls (zero references; cover
letter re-compiled and page-verified after removal).
Guard-list gap surfaced by CodeRabbit's review on jakob1379's Nix demo
fork PR (jakob1379/ai-job-search#1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(add-template): make /add-template engine-agnostic so Typst can register alongside LaTeX
/add-template hardcoded a lualatex|xelatex|pdflatex engine enum and .tex
assumptions, so custom templates could only be LaTeX. Replace the enum with a
declared source extension + compile command, so any toolchain (Typst via
`typst compile`, or others) registers the same way stock LaTeX templates did.
Stock CV/cover-letter pipeline stays LaTeX and untouched (per #181).
Also fixes a latent bug this surfaced: apply.md's compile step ignored the
ACTIVE-TEMPLATE block and always ran lualatex/xelatex on .tex regardless of
the active template, and .gitignore's cv/main_*.tex pattern would not have
ignored a non-.tex draft (personal-data leak). Both now resolve from the
declared extension/command.
* fix(add-template): satisfy security_guards on the .gitignore Typst fix
security_guards.py pins the personal-data ignore rules by exact string and
gates negations through an allowlist, so broadening cv/main_*.tex and
cover_letters/cover_*.tex to *.* (for .typ drafts) needed a matching update
to REQUIRED_IGNORE_RULES.
Also tighten the .gitignore itself per review: keep the re-include
negations at .tex instead of widening them to *.* too. The stock example
files are always LaTeX, so .tex is enough to re-include them, and a
wildcard negation would have also re-included build artifacts
(main_example.pdf/.aux) that should stay ignored. ALLOWED_IGNORE_NEGATIONS
needs no change since the negations are unchanged.
Also adds a CHANGELOG entry under Unreleased for the Typst/custom-template
support.
* feat(08): add application-form fields as a third /apply artifact
/apply produces a CV and a cover letter. Many applications need a third
thing: free-text typed into a portal. Graduate programs, large-employer
ATS systems and startup forms ask for self-introductions, structured
project entries, motivation questions and pitches under a hard character
limit - none of which either document covers, and all of which the
interviewer reads alongside the CV.
Governing rule: a form field selects from what is already true and
arranges it for the question asked. It never introduces a new claim.
All accuracy rules from 03 and 05 apply unchanged.
Covers three field types (self-introduction, structured project entries,
hard character limits), the output format (a plain .txt the candidate
pastes from, with counts stated and internal NOTE TO SELF blocks marked
as not-for-pasting), and a verification checklist.
Two places where form fields are stricter than a CV, because both are
easy to get wrong:
- Project entries carry a name and a role, so they read as ownership of
the whole project in a way a terse CV bullet does not. Contributory
work has to be scoped inside the description.
- Project dates are the dates of the project, not of the employment.
Narrowing them is more accurate where the candidate can say when the
project started - but never invent a boundary to improve the ratio.
Registers the file in SKILL.md (framework_version 1.0.1 -> 1.1.0) and in
the FRAMEWORK_FILES list in tools/check_upstream_updates.py, so it is
covered by the update check like every other framework file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fixup(08,apply): wire as optional /apply offer; align grounding to three-source union
- apply.md Step 6: offer the third artifact after CV/cover letter are
produced, mirroring the /outcome house pattern for optional capabilities
(offer, act only on yes, default output unchanged).
- 08-application-forms.md: ground claims against the framework's
01-candidate-profile.md + master CV + CLAUDE.md union (per #185)
instead of only 01, in both the governing rule and the checklist.
Per MadsLorentzen review on PR #212. Rebase onto merged #210 (the
tenure-check reference this file cites) still pending — #210 hasn't
landed on upstream/master yet.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
An unmatched count column (e.g. a lone total headcount with no paired index
column) was appended as an untyped standalone value and stored under "index",
even though detect_column_type had already classified it as a count.
salary_lookup then rendered the raw headcount as a salary index with a
meaningless "vs baseline" percentage.
Tag unmatched count columns with field="count" so the row parser stores them
under "count" (as an int, matching the paired-count branch). Standalone index
and untyped columns are unaffected.
The sequential scan assumed count/index pairs are always adjacent.
Interleaved columns like Count_A, Count_B, Index_A, Index_B produced
wrong pairings (Count_B ↔ Index_A), silently corrupting data.
Now columns are grouped by type, then matched by the category name
derived from stripping type words. Unmatched columns fall back to
standalone value columns using the original header name.
job_scraper/seen_jobs.json (and notion_sync.json / *.md) were ignored by a repo-rooted pattern, but the job-scraper skill resolves job_scraper/ relative to its own directory, so the state file lands at .claude/skills/job-scraper/job_scraper/ and the rule never matched - publishing every scraped posting with fit scores and skip-reasons on a public fork. Switches to **/-prefixed patterns that match at any depth (the rooted location still matches too, so no regression), and adds documents/interview/** (interview prep names employers, quotes submitted material, and lists the candidate's weak points) - it was never ignored though documents/applications/** was. REQUIRED_IGNORE_RULES updated in lockstep so the security guard stays in sync.
By @LeoWinston-9596 (split from #199). Verified: both nested and root seen_jobs.json now ignored, interview records ignored, guard suite green (17 tests incl. #195's negation checks). Rebased cleanly on current master.
convert_salary_excel.py detected the city column via exact membership (h_lower in CITY_PATTERNS), so real headers like "City Name", "City/Kommune", or "Kommune <suffix>" never matched and every company was written with an empty city field. Switches to header_matches(h, CITY_PATTERNS) - the same whole-token matcher already used for the company, count, index, and ID columns. Same bug class as #151 (company column); bare "City"/"Kommune" inputs are unaffected. Regression test covers bare and suffixed headers.
By @oscarbol09.
check_gitignore() verified each required personal-data rule was present via set membership, but .gitignore is order-sensitive: a later !pattern re-includes a file an earlier rule excluded, so the required line stays physically present while the file is no longer ignored - the guard failed open on exactly the weakening its docstring claims to catch. Keeps the required-rules-present check and additionally rejects any negation line outside a small reviewed ALLOWED_IGNORE_NEGATIONS allowlist (same explicit-widening pattern as ALLOWED_PERMISSIONS). Fixes#194.
By @thejesh23. Verified: allowlist matches the four negations currently in .gitignore; guard test suite passes locally (17 tests) and in CI.
Closes#194
Root AGENTS.md pointer file per the architecture decision in discussion #78: documents both config roots (.agents/skills/ portable portal skills, .claude/ orchestration) and the profile entry points, carries a framework_version marker registered in both version tools.
Design case made by @erikpr1994 in the #78 architecture thread; implementation by @jovin-nicholas.
validate_data() accepted category values that are not {count?, index?}
objects. They slipped through to format_entry(), which then raised
AttributeError on a normal table lookup (or serialized a malformed shape
under --json). It also accepted duplicate company names silently.
- collect_validation_issues() now also flags a non-object category value
(and non-numeric count / non number-or-string index) as a hard error,
and duplicate company names as a warning.
- validate_data() keeps its eager-fail behavior (same messages), so
existing tests and load_data() are unchanged.
- --validate runs the checks standalone and prints an actionable report
(exit 1 on errors, 0 on warnings-only/clean), letting users pre-flight
their BYO salary_data.json.
Reproduced on master: validate_data({'companies':[{'company':'Acme',
'categories':{'eng':'not_a_dict'}}]}) returns without error, but
format_entry then raises AttributeError.
Co-authored-by: Tunic Assistant <assistant@tunic.local>
parse_sheet treated every column that was not company/city as a salary category, with no check that the column actually held numeric salary data. This turned free-text columns (e.g. Notes) into bogus string categories and numeric identifier columns (e.g. Id) into mistaken salary indexes.
- Drop identifier headers (ID_PATTERNS = {id, personnummer}) at classification time.
- Skip non-numeric standalone values and fully-null count/index pairs at row-processing time.
- Adds regression tests (skips_free_text_column, skips_numeric_identifier_column, keeps_numeric_salary_column) that fail on master and pass after the fix.
convert_salary_excel.py detected the company column via exact membership
in COMPANY_PATTERNS, so common real-world headers like "Company Name" or
"Employer Name" were never matched. parse_sheet then returned [] for that
sheet, silently dropping it from salary_data.json (or exiting with no
output for a single-sheet file).
Route company-column detection through the existing header_matches()
token matcher (already used for count/index detection). This only adds
detections; inputs that already worked (bare "Company"/"Firma"/...) are
unaffected.
Adds a regression test in tests/test_convert_salary_excel.py that fails
on master (returns []) and passes after the fix.
* Add framework_version markers to assistant skills and implement CI version guard and update checker
* Address review feedback: update ci.yml based on latest upstream, gate CI version guard to upstream repo, and remove non-ASCII characters from check_upstream_updates.py
Valid JSON such as [] or {"permissions": []} caused lint_skills.py to raise AttributeError because it assumed both values were objects.
Validate the top-level settings value and permissions object before reading nested keys. Malformed settings now produce clear lint errors and exit 1 without a traceback.
Add subprocess regression tests covering invalid JSON, malformed root values, invalid permissions values, and non-list permissions.allow values.
This commit improves the performance and consistency of the salary tools:
- Redundant query normalization and word extraction are eliminated in salary_lookup.py by pre-calculating representations once before the search loop.
- A match_score_optimized helper is introduced to perform the comparison using the pre-calculated query data, preserving full backward compatibility for match_score.
- Normalization in tools/convert_salary_excel.py is unified: paired column headers now consistently substitute spaces and dashes with underscores (e.g. 'software_engineering') to match the single-column formatting.
- Unit test coverage is significantly expanded in tests/test_salary_lookup.py and tests/test_convert_salary_excel.py to cover normalization, anglicization, search filtering, and matching behaviors.
* refactor(salary): make compound-word matching locale-agnostic
The Excel column detector hardcoded a DANISH_COMPOUND_PATTERNS set inside
header_matches(), so the compound-word matching that helps Danish headers
(e.g. "lønindeks") was baked into the algorithm by name and unavailable to
any other locale without editing the source.
Rename it to COMPOUND_PATTERNS and pass it as a parameter (default
unchanged, so the Danish demonstration data behaves identically). A
different-locale spreadsheet can now supply its own compound tokens via
header_matches(..., compound_patterns=...). Add a test covering both the
preserved default and the parameterized path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(salary): drop unused compound_patterns parameter
Per review: keep the DANISH_COMPOUND_PATTERNS -> COMPOUND_PATTERNS
rename (universal template naming, defaults still Danish), but remove
the compound_patterns= parameter. No caller passes a custom set, and a
fork adapting another locale edits the module-level constant either way,
so parameterizing it is speculative generality per CONTRIBUTING.md.
header_matches() now reads COMPOUND_PATTERNS directly. Test updated to
verify compound-vs-whole-token matching against the constant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: add supply-chain guards - permission allowlist, gitignore rules, manifest checks, pinned actions
This template's threat model is unusual: it ships pre-approved Claude Code
permissions (.claude/settings.json) and CLI code that every fork user
executes via those permissions. A plausible-looking PR could therefore
ship risk to every forker: widen a permission to Bash(*), weaken the
personal-data gitignore rules, or smuggle code execution into bun install
via a lifecycle script. Nothing checked for these mechanically.
New job security-guards runs tools/security_guards.py (stdlib only):
- settings.json: every permissions.allow entry must be in an exact,
in-repo allowlist. The guard makes permission changes loud, not
impossible - a PR that intentionally widens permissions must update
the allowlist in the same diff, so the widening is explicit and
reviewable
- .gitignore: the personal-data rules (tracker, documents/**, cv/main_*,
salary data, seen_jobs) must all still be present - the mirror image
of the placeholder-integrity job
- .agents/**/package.json: no lifecycle scripts (preinstall/install/
postinstall/prepare/prepack) and no trustedDependencies, which would
execute arbitrary code during bun install on users' machines
New job dependency-review (PRs only): actions/dependency-review-action
flags newly introduced vulnerable or malicious dependencies,
fail-on-severity high.
Workflow hardening: explicit top-level permissions: contents: read
(least-privilege token), and all actions pinned to commit SHAs resolved
from the same major tags already in use (checkout v4, setup-python v5,
setup-bun v2), with the tag recorded in a comment.
Honest limit, recorded in the workflow header: a PR can edit this
workflow itself, so these guards catch accidents and casual attempts,
not a determined author. Branch protection with required checks and
human review of workflow/settings diffs remain the real backstop.
Verified locally: positive run passes; injecting Bash(*) into
settings.json, deleting the tracker gitignore rule, and adding a
postinstall script each fail the guard with the intended message, and
reverting restores a clean pass.
* ci: scope dependency-review to upstream PRs - forks lack Dependency graph by default
Verified on a fork: the action fails with 'Dependency review is not
supported on this repository' until Dependency graph is manually enabled,
and forks don't inherit it. Guarded with the same
github.repository == upstream condition the other upstream-only jobs use.
With the graph enabled the action passes, so the config itself is sound.
* ci: probe Dependency graph before dependency-review - warn and pass when unavailable
The upstream PR run showed Dependency graph is disabled on the upstream
repo too (the action hard-fails: 'Dependency review is not supported on
this repository'), not just on forks. Only the repo owner can enable it,
so a hard red X here is friction, not signal.
The job now probes the dependency-graph SBOM endpoint with the workflow
token first: HTTP 200 runs the real review; anything else emits a
::warning:: naming the setting to flip (Settings -> Advanced Security ->
Dependency graph) and passes. Same graceful-skip pattern the workflow
uses for optional tools - the check self-activates the moment the graph
is enabled, no workflow change needed.
- Untrack job_search_tracker.csv: it was both tracked and listed in
.gitignore (same inconsistency class as the settings.local.json fix
in #27). Users' personal rows risked merge conflicts on every pull;
commands already create the file with the standard header when it
is missing.
- Scope job-scraper's allowed-tools Bash entry (from #52) to
'bun --version' and the portal-CLI invocation pattern, adopting the
tighter form proposed in #65.
- Fix all five portal SKILL.mds documenting 'bun run skills/...'
paths that do not resolve from the repo root ('.agents/skills/...'
is correct) - now load-bearing since #52 wired /scrape to read
these docs for CLI invocations. Surfaced in #66.
- Teach tools/lint_skills.py to glob-expand allowed-tools bun run
targets so scoped wildcard permissions lint correctly.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Every PR to this repo is currently verified by hand. This adds the checks
a machine can do:
- latex-smoke: compiles cv/main_example.tex (lualatex) and the new
cover_letters/cover_example.tex (xelatex) in the texlive/texlive
container, failing on any LaTeX error. Exact page-count assertions
(CV=2, cover letter=1) run on the upstream repo only
- lint (tools/lint_skills.py, also runnable locally): every SKILL.md has
parseable YAML frontmatter with name+description (frontmatter breakage
happened before - 37a0eed), allowed-tools 'bun run <path>' targets
exist, command files start with a '# /<name>' title, settings.json is
valid JSON with a permissions.allow list
- cli-typecheck: bun install + tsc --noEmit for all five portal CLIs
(matrix, fail-fast off)
- placeholder-integrity (upstream only): tracked template files still
carry their placeholder tokens, catching accidental personal-data
commits before they land
Fork-friendly by design: /setup personalizes CLAUDE.md, the skill files,
and main_example.tex in forks, so placeholder checks and exact page
counts are guarded with github.repository == upstream; compile success
and lint run everywhere. Live CLI smoke tests are deliberately excluded:
network-flaky, and linkedin-search is personal-use-only per its own ToS
warning - CI-automated requests would violate it. CLIs are typechecked
instead.
The cover letter previously had no tracked example (cover_*.tex is
gitignored), so cover_example.tex is new: a placeholder letter following
the documented 06 structure, demonstrating the correct itemize-outside-
lettercontent pattern. It doubles as the structural reference /apply
Step 2 looks for on fresh clones, which until now matched nothing. The
gitignore exception is ordered after Cover_*.tex because case-insensitive
filesystems match that pattern against cover_example.tex too.
Writing it surfaced a latent bug in the documented template itself:
06-cover-letter-templates.md's structure ends with \closing{Kind
regards,\} - but cover.cls appends its own \, and the doubled break
produces '! LaTeX Error: There's no line here to end.' on every compile
(nonstopmode swallows it, so it went unnoticed). Fixed in 06 and noted
in the example.