Commit Graph
76 Commits
Author SHA1 Message Date
Rudy Mizrahi Celekli 1b22a38d3f fix(upstream-triage): suggest dependent cherry-picks in ancestry order (#574) 2026-10-17 20:04:06 +02:00
Ayobami Adegoke 7fe4ea3781 fix(verify_pdf): --ascii-dates treats a numeric range as a date only when both ends are bare years (#533)
month" and "2000-5000 requests per second" still failed /apply Step 5d:
each has a four-digit number next to the dash that happens to fall in
1900-2099, and the guide tells users to keep "--" in exactly those
prose ranges.

When both sides of the dash are bare numbers - the whole
whitespace-delimited token, outer punctuation stripped, matching
\d+(?:[.,]\d+)*[kKmM%]? with no year group among its parts - count the
match only if both are years. The salary band and the throughput range
pass; 2016-2024, 1990-2010 and "2016 - Present" still fail as before,
and a numerically formatted range (03/2016-07/2024, 01.2016 - 12.2024,
2016-03-2024-07, 2016/17-2018/19) is not a bare number on either side
and is still caught. On the stock CV with all three prose ranges on one
line, master reports three hits and this branch reports the one real
date.

Three new cases; the quantity one fails on master.
2026-10-17 19:48:25 +02:00
Nishil RathodandClaude Opus 5.5 a203bdd08f fix(rank): retry postings that could not be fetched instead of expiring them (#564)
* fix(rank): retry postings that could not be fetched instead of expiring them

A posting /rank could not fetch (403, login wall, 404, timeout, empty
page) and one that is really closed both became `expired`, which is
terminal: `candidates` only selected `new`, so a transient failure on
ranking day removed a live job from the pipeline and the report
presented it as closed.

Shape per the maintainer's decisions on #534:

- Scoring agents return a third status, `unreachable` (with a short
  `fetch_error`), after exhausting the 09-web-research.md escalation
  order; `expired` stays for positive evidence of closure (closed page,
  410, redirect to a listing page, passed deadline). A 404 is
  `unreachable` first (#538 is that case).
- `candidates` selects `unreachable` alongside `new`. `apply` increments
  `fetch_attempts`, keeps `first_unreachable` (UTC timestamp) and stores
  `last_fetch_error` (short reason, no page text). After 3 attempts with
  at least 48 hours between the first and the last it writes `expired`
  with `expired_reason: "unreachable"` and returns the job once under
  `gave_up`, which Step 5 prints under "Gave up, check manually" with
  its URL. A later successful fetch, an explicit `expired` or a passed
  deadline clears the retry fields. `--all` still revives everything.
- rank_state.py owns the status set (`AGENT_STATUSES`); `apply` rejects
  any other status instead of reporting a misleading missing score, and
  test_rank_command.py asserts rank.md's JSON contract names the same
  set. A new `--now` option makes the 48-hour floor testable.
- rank.md's step text (Step 2 rules and contract, rule 6's --all note,
  Step 4 fields, Step 5 report, Important Rule 1) updated together.
  CHANGELOG under Changed: no existing entry carries the new status, so
  no migration.

Reported by @ai-tvang in #534, who also proposed the time floor and the
give-up listing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(rank): expire an unreachable posting whose deadline has passed

Review on #564:

- The unreachable branch in apply returned before the deadline check the
  scored path runs, and candidates hands out `new` entries whose stored
  deadline has passed. So a posting that closed on 2026-09-20 and
  returned 403 on 2026-10-05 was retried, then listed under "Gave up,
  check manually" as possibly live. A passed stored deadline is now
  checked first and takes the expired path, dropping the retry fields,
  as on master and under rule 5. New test pins it.
- 09-web-research.md step 5 said that in /rank an unretrievable posting
  is marked `expired`, so an agent following it could write a 403 off
  and bring #534 back. It now says `unreachable` (framework_version
  1.1.2 -> 1.1.3); the CHANGELOG entry says so.
- The report shows `attempt N` instead of `N/3`, which read 4/3 while a
  job waited out the 48-hour floor, and job-scraper's dedup note names
  `unreachable` alongside `ranked`/`expired`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:46:06 +02:00
Oscar Madera 011a8b8435 fix(upstream_triage): force UTF-8 stdout and decode git output as UTF-8 (#544) (#547)
* fix(upstream_triage): force UTF-8 stdout and decode git output as UTF-8 (#544)

* test(tools-utf8): restore assertion in salary converter test and isolate upstream_triage test
2026-10-17 19:44:56 +02:00
Oscar Madera f375ef7593 fix(robots-check): unquote query strings and restrict '$' anchor to pattern end (#543) (#546) 2026-10-15 11:48:21 +02:00
Oscar Madera e583b09bfa fix(security): ignore .claude/settings.local.json and pin in security_guards (#551) (#552) 2026-10-15 11:41:55 +02:00
Rudy Mizrahi Celekli 889b82bf13 fix: expire scored jobs with passed preserved deadlines (#560) 2026-10-15 11:39:10 +02:00
落尘 8a3094f138 feat(tools): install every discovered portal CLI with one command (#540) (#541)
README and SETUP.md hardcoded the six shipped portals in four PowerShell/Bash
loops, so a portal added by /add-portal or borrowed from another fork was
skipped on a fresh clone. tools/install_portal_clis.py uses CI's discover-clis
glob, isolates per-CLI failures, and skips manifests with lifecycle scripts or
trustedDependencies.
2026-10-15 11:32:17 +02:00
Ayobami Adegoke da2aee7bf1 fix(job_key): keep the bare basis for an empty title whose URL has no six-digit run (#523)
The mixed-script rule from #514 appended the digest to `basis` as well,
so an empty title with a URL that carries no six-digit run moved from
"acme_https-example-com-jobs-abc-xyz" to "..._5ca8c6". Unreachable from
/scrape, which always passes a title, but a key is a pure function of the
posting and should not move for a case the rule was never about.

A lossy fragment still takes the digest; an empty title keeps the bare
basis or "untitled-<hash>" exactly as before #514. One new case pins the
pre-#514 output and fails on current master. The CHANGELOG example for
the Python-разработчик key is corrected to its real digest (python-fb29d7).

Follow-up suggested in the #514 merge note.
2026-10-08 21:18:32 +02:00
Ayobami Adegoke 0c9bb3cb07 fix(job_key): hash mixed-script names instead of keying on their Latin fragment (#514)
The hash fallbacks from #487 and #502 fired only when the slug was
completely empty. A mixed-script name kept its Latin or digit fragment
and skipped them: "Программист 1С" and "Аналитик 1С" at one company both
keyed as company-6d769a_1, "Python-разработчик" and "Python-аналитик" as
..._python, "Сбер AI" and "Яндекс AI" as ai_... /scrape Step 4 then
dropped the second posting as already seen, and because the dict key is
overwritten, --audit could not report the collision as a duplicate URL.

Treat the fold as lossy whenever it drops a letter outside the Latin
script, and let the existing fallbacks take over: the title half uses
the portal's numeric id or the URL hash with the fragment kept as a
readable prefix, the company half uses the NFC-casefold name hash with
the fragment as prefix. Latin letters that also lack a decomposition
(ø, æ, ß, ł) are deliberately not counted, so "Ørsted" still keys as
"rsted" and a live Danish seen_jobs.json does not re-key.

Seven new cases; the four collision cases fail on master.
2026-10-05 19:20:49 +02:00
Ayobami Adegoke 52f84e0f82 fix(verify_pdf): --ascii-dates needs digit boundaries around the year (#512)
_YEAR matched (19|20)\d{2} anywhere, so `2000` inside `120000` or `12000`
made a budget or throughput range typed with `--` read as an en-dashed
date range: "Grew budget DKK 120000-200000" and "12000-15000 events/s"
both failed /apply Step 5d, and the agent was sent to fix a date argument
that does not exist. The guide promises a numeric range with no year is
left alone.

Bound the year with (?<!\d) and (?!\d). "2016-2024" next to a longer
number is still caught. Two new cases; the numeric one fails on the
unbounded pattern.
2026-10-03 19:33:48 +02:00
Timofei ErokhinandClaude Sonnet 5 27a9b4a91d fix(check_framework_version): decode git diff output as UTF-8 (#511)
* fix(check_framework_version): decode git diff output as UTF-8

run_git() passed subprocess.run(text=True) with no explicit encoding,
so it fell back to the host locale's default codec - cp1252 on
Windows - instead of UTF-8. Any non-ASCII byte in a modified
framework file's diff (e.g. an em dash in a profile edit) then raised
UnicodeDecodeError before the gate could evaluate the change, instead
of a pass/fail verdict. Reproduced on a real Windows checkout.

Pin encoding="utf-8" on the subprocess.run() call. Added a regression
test that patches subprocess.run and asserts run_git() always passes
encoding="utf-8" - confirmed it fails against the pre-fix code and
passes against the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(check_framework_version): address review on UTF-8 decode fix

Per MadsLorentzen's review on PR #511:
- Add errors="replace" alongside encoding="utf-8" on the subprocess.run()
  call, matching the convention used elsewhere in this repo (robots_check.py).
- Correct the docstring: an em dash does not crash the un-pinned decode
  (cp1252 maps it fine); the actual trigger is a byte cp1252 leaves
  undefined, e.g. Cyrillic Ё/ё, much CJK, or Á-class Latin.
- Correct "profile file" to "framework file" - the tool only diffs the
  framework's own directories, not arbitrary profile files.
- Add a CHANGELOG entry under [Unreleased] / Fixed naming the tool and test.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 20:03:35 +02:00
1cecaec4d3 fix(robots-check): stop a leading BOM or an undecodable rule from reading as allowed (#509)
* fix(robots-check): stop a leading BOM or an undecodable rule from reading as allowed

Two decoding edge cases left the robots gate failing open. Both were
flagged as follow-ups in #506.

A robots.txt saved with a UTF-8 byte-order mark decodes to a body that
starts with U+FEFF, so its first field read as U+FEFF + "user-agent". A
leading "User-agent: *" went unseen, every rule in that group was
dropped for want of an agent, and "Disallow: /" read as allow-all.
_groups() and is_robots_body() now skip one leading U+FEFF, as Google's
reference parser does, which covers every caller of allowed(). This
predates #506: the old code-page decode hid the line the same way.

Since #506 pinned curl's output to UTF-8 with errors='replace', a
non-conformant robots.txt saved in a legacy code page (cp1254,
ISO-8859-9) with raw non-ASCII bytes in a rule decodes them to U+FFFD.
That rule can never match and was silently skipped. The new
has_undecodable_rule() treats such a body as unreadable, so the gate
returns UNCONFIRMED (exit 1). The field is named with U+FFFD removed, so
a stray byte before "Disallow" cannot hide the line either. U+FFFD in a
comment or another field (Sitemap, Crawl-delay) stays harmless, and
valid UTF-8 non-ASCII rules decide as before.

Nine new tests in two FAIL-OPEN REGRESSION classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): record the robots_check BOM and U+FFFD fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Can Mugan <217335573+canmugan@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:56:31 +02:00
Yang e355eafe27 fix(verify-layout): find an orphaned entry header by where the text starts (#508)
find_orphans() compared each line's left edge with the document margin:
an un-indented last line followed by an indented first line was an entry
header orphaned from its bullets. A list marker moves a line's left edge
without moving its text, so the rule misread two templates:

  * one that merges each bullet's marker into its first line (#481): a
    bullet wrapping across the break was reported as an orphaned header,
    and a real orphaned header followed by such bullets was not reported;
  * the stock CV, when its outer marker extracts as a line of its own: a
    [Job Title] header left at the foot of a page, bullets overleaf, was
    not reported (its text at x70.4 counts as indented), and a one-line
    list item whose outer marker is set 1.3pt below it - so the marker
    sorts last on the page - was reported as a split bullet although the
    whole item is on that page.

Following discussion #481, the rule now reads text x:

  * parse_pdf records where a line's text starts after leading
    LIST_MARKERS words (Line.text_left, defaulted). The set is the agreed
    Unicode bullets without the dashes (`• ‣ ▪ ● ·`): a bullet's second
    line may open with an em dash, and the stock Honors item's second line
    opens with an en dash. Other glyphs are text, so a line that merges
    the stock outer marker (`○` on some setups, an emoji on others) keeps
    the marker's x, as the left-edge rule read it;
  * find_orphans compares the text x of a page's last printed line with
    that of the next page's first printed line, and reports an orphaned
    header when the latter is more than INDENT_PT further in. Bbox lines
    whose tops are within SAME_PRINTED_LINE_PT (3pt) are one printed line:
    pdftotext's rounded-yMin buckets split a line where a bold word sits
    0.2pt off, or where a marker sits 1.3pt below its text. A line whose
    text starts more than SET_IN_LIMIT_PT (60pt) past the margin is set
    right or centred (a running header) and is skipped as the opener;
  * a page ending on a marker with no text beside it keeps the lone-marker
    report, with its condition and message unchanged.

Stock cv/main_example.tex, \vspace* of 0-600pt in 8pt steps before four
entries, 304 lualatex builds (and the same 304 with xelatex): this adds
15 reports, each a [Job Title] header at the foot of a page with its
bullets overleaf; the other 289 builds give byte-identical output. The
reported.

A two-line outer item whose marker sorts last and that wraps across the
break is no longer reported as a split bullet: it is read like the #481
wrapped bullet, which is expected clean.

Tests: TestOrphansReadTextX (13; 7 fixes and the wrapped two-line item
fail on master's logic, 5 pin behaviour master already had) and
TestTextStart (4). On Python 3.10-3.14 `python -W error::SyntaxWarning
-m unittest discover -s tests -t .` passes 520 tests (master 503);
lint_skills.py, check_framework_version.py and security_guards.py OK.
2026-10-01 21:53:33 +02:00
Ahmed Ashraf a56b8d0414 fix(job_key): hash the URL alone so a re-listed non-Latin title keeps one key (#502)
* fix(job_key): hash the URL alone so a re-listed non-Latin title keeps one key

* docs(changelog): record the job_key URL-identity fix

Adds the [Unreleased] / Fixed entry for #501, naming tools/job_key.py and
tests/test_job_key_url_identity.py.

The entry states the migration consequence in bold: an entry written before
this fix, for a non-Latin title on a portal without a numeric id, re-keys once
on the next scrape. It also names where those entries surface,
keys_not_matching_current_rule, and that --audit never rewrites them.

Verified that claim rather than asserting it. A seen_jobs.json holding one
entry under its pre-fix key for a Cyrillic-titled freehire URL reports
keys_not_matching_current_rule: 1 under the fixed audit. The same audit over a
real 884-entry seen_jobs.json reports 0, because those keys are the
company_title form that is_canonical() excludes from the drift check.
2026-10-01 21:45:19 +02:00
9bc71de5f8 fix(robots-check): decode curl output as UTF-8 so non-ASCII responses stop reading as UNCONFIRMED on Windows (#506)
_fetch() ran curl with text=True and no encoding, so Python decoded the
response with the Windows ANSI code page. On a byte that code page leaves
undefined (0x81 in cp1252 and cp1254, 0x9e in cp1254) subprocess's pipe
reader thread died, stdout came back None, rpartition() raised
AttributeError, and the gate printed "UNCONFIRMED (AttributeError)". That
fails closed, but it blocked the browser-header retry 09-web-research.md
permits on hosts such as tr.indeed.com (a UTF-8 CJK Disallow rule) and
kap.org.tr (a UTF-8 Turkish 404 page).

Pin encoding='utf-8', errors='replace' on the call (RFC 9309: robots.txt
is UTF-8), as verify_pdf.py and verify_layout.py already do. Decision
rules and the fail-closed handling of real read failures are unchanged.

The regression tests swap curl for a child process that prints the real
bytes, so the actual subprocess decode path runs with _fetch's own
kwargs, and pin cp1254 when no encoding is passed so a UTF-8 host cannot
pass the bug by luck. Both fail on master.

Co-authored-by: Can Mugan <217335573+canmugan@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:18:48 +02:00
Ayobami Adegoke a45bbd1a2a feat(verify_pdf): add --ascii-dates, the non-folding check for the documented date-range rule (#385) (#498)
* feat(verify_pdf): add --ascii-dates, the non-folding check for the documented date-range rule (#385)

05-cv-templates.md records a real Workday import that dropped a role's
end date because `2016--2024` reaches the text layer as 2016<U+2013>2024,
and tells Step 5d to confirm every entry's years are joined by an ASCII
hyphen. Nothing executed that. The natural probe, --contains "2016-2024",
failed identically for "en-dashed" and "absent" before #458 and passes on
both since, because the fold maps the en-dash back to "-" on purpose.

Add --ascii-dates: scan the raw text layer (never the fold) for a year
joined to any Unicode dash or the minus sign and fail naming each hit
with its line and code point. A year on either side of the dash is
enough (Mar 2016 - Jul 2016, 2016 - Present); a numeric range with no
year is not a date. The dump is still written before the check fails.

Wire it in: Step 5d's extraction command carries the flag and the
"Dates recognizable" item points at it, keeping the bare single-year
half as a read-through; the guide's "add this to the step 5d checks"
paragraph names the command (framework_version 1.4.4 -> 1.4.5); the
upstream-only CI assertion runs it on the stock CV.

Demonstrated on the stock template compiled with lualatex through both
extractors: a \cventry{2016--2024} build fails with U+2013 while
--contains "2016-2024" passes on it; the \cventry{2016-2024} build passes
both; the untouched stock CV passes the CI assertion.

Ten new test_verify_pdf.py cases. Proposed by 9scorp4 in Discussion #385.

* fix(verify_pdf): --ascii-dates only joins a year and a dash across horizontal whitespace

`\s*` between the year and the dash also matched a newline, so a heading
ending in an ASCII date ("Heading 1988-1994\n- note") joined the dash
that merely opened the next line and was reported as a U+2013 range; the
mirror shape ("Six Sigma -\n2016 onwards") did the same. Both are real
text-layer shapes: a dash used as a bullet, a wrapped prose line.

Match horizontal whitespace only ([^\S\n]*). Every existing case still
passes; three fixtures pin the two newline shapes and the same-line
spaced dash that must still be caught.

Found in review by yang2632, with the regex and the fixture.
2026-09-28 19:06:41 +02:00
Yang a62af6a068 fix(verify-layout): escape the docstring's LaTeX macro so the tool stops emitting SyntaxWarning (#480)
`tools/verify_layout.py`'s module docstring explains which documents trigger
the Poppler `-bbox` crash, and names the macro whose absence causes it:
`\hypersetup{pdftitle=...}`. In a non-raw docstring `\h` is an invalid escape
sequence, so compiling the module prints

    SyntaxWarning: "\h" is an invalid escape sequence.
    Such sequences will not work in the future. Did you mean "\\h"?

on Python 3.12+ (CPython gh-98401 promoted the long-standing
DeprecationWarning to a SyntaxWarning). The 3.15 language reference still
documents the sequence as a SyntaxWarning, with a SyntaxError only "in a
future Python version".

This is on the real path, not a constructed one: /apply Step 5b runs
`python3 tools/verify_layout.py <pdf>` once per document, so the first
invocation after any source change prints the warning into the middle of the
layout report a user is reading to judge their CV. It is also visible in this
repo's own CI log, where `python -m unittest discover -s tests -t . -v` prints
it above the test results.

Reproduce on master:

    python3 -W error::SyntaxWarning -c "compile(open('tools/verify_layout.py',
      encoding='utf-8').read(), 'tools/verify_layout.py', 'exec')"

raises on master; after this change it is silent.

Escaping (rather than making the docstring raw) keeps the surrounding prose
unchanged, and the single-backtick markup matches the other inline literals in
the same docstring. The rendered text still reads `\hypersetup{...}`.

The new `ToolsCompileWithoutWarnings` case is a pure `compile()` of the source
text of every `tools/*.py` inside `warnings.catch_warnings(record=True)` and
asserts no `SyntaxWarning` or `DeprecationWarning` (3.10 and 3.11 report the
same escape as a DeprecationWarning) - no cache file, no temp file, so it runs
the same on Windows (py_compile's atomic rename over an open
NamedTemporaryFile is what the first version tripped on). It fails on master
and passes here on every Python in the CI matrix, and it guards the next
docstring that quotes a macro.

Verification on Python 3.10, 3.11, 3.12, 3.13 and 3.14: `python3 -m unittest discover -s
tests -t .` (488 tests OK; master is 487), `python3 -W error::SyntaxWarning`
compile of every `tools/*.py`, `tests/test_changelog_structure.py`.
2026-09-27 19:28:00 +02:00
Ayobami Adegoke 120f476a08 fix(rank): preserve Unicode identity in tracker matching (#486) 2026-09-21 08:41:22 +02:00
Mads LorentzenandClaude Fable 5.1 ba4f2397a3 fix(tools): write UTF-8 on stdout so non-Latin postings stop crashing on Windows (#490)
A piped stdout on Windows defaults to the ANSI code page (cp1252 on most
Western installs). rank_state.py printed titles and companies with
ensure_ascii=False, so one Cyrillic, CJK, Devanagari, Polish or Turkish
posting ended /rank with UnicodeEncodeError. job_key --audit, salary_lookup,
convert_salary_excel and verify_layout failed the same way.

Each tool now reconfigures stdout and stderr to UTF-8 at entry. The new
tests run every tool in a child process with a cp1252 stdout forced through
PYTHONIOENCODING, so Linux CI reproduces the Windows failure.


Claude-Session: https://claude.ai/code/session_01WPXraLZ4i7UW4xGn9tVcox

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 08:37:18 +02:00
Ayobami Adegoke 2e8600d69d fix(rank): reject invalid score dimensions before saving results (#488) 2026-09-21 08:26:43 +02:00
Ayobami Adegoke 456f2bfdbf fix(scrape): distinguish non-Latin companies in fallback job keys (#487) 2026-09-21 08:24:15 +02:00
Ayobami Adegoke 6a308c5cc8 fix(rank): handle UTF-8 BOM in tracker CSV headers (#485) 2026-09-21 08:21:04 +02:00
Ayobami Adegoke 968fb1bd7e fix(salary): pair a bare Count/Index column pair instead of splitting it into two categories (#470)
The pairing loop required a non-empty derived category name on both
sides, but a header with no category word - "Count" + "Index", Danish
"Antal" + "Lønindeks" - strips to an empty name, so the simplest layout
the README advertises ("auto-pairs count/index columns") came out as two
unrelated standalone categories:

  {"count": {"count": 500}, "index": {"index": 108.5}}

salary_lookup then rendered a "Count  500  N/A*" row above an
"Index  -  108.5" row, and its footnote read the N/A* as "too few
employees to publish (privacy)" - a false statement about a company whose
headcount is in the file, shown during /apply's salary step. Any suffix
("Antal alle") made pairing work, which is why the shipped tests, all
suffixed, never saw it.

Pair on equal derived names, empty included, and give the nameless pair
the README's top-level category name (all_employees). A bare "Antal" with
no bare index column still stays a standalone count; named pairs
alongside are untouched.

Four new cases in test_convert_salary_excel.py, one of them rendering the
converter's output through salary_lookup.format_entry; all four fail
against the old pairing rule.
2026-09-16 06:47:45 +02:00
Oscar Madera e92f7d9065 feat(setup): add documents/projects/ ingestion for independent projects (Path A) (#468) 2026-09-16 06:47:15 +02:00
Souptik Chakraborty 1b65f7198a fix(verify-layout): name both causes of a broken bounding-box extractor (#451) (#465)
verify_layout.py's skipped: message blamed only the xpdf-based pdftotext that
Git for Windows puts ahead of Poppler in PATH. A real Poppler can abort too:
26.0x before 26.05 crashes -bbox/-bbox-layout/-htmlmeta on a PDF whose Info
dictionary carries an empty string in any field, and hyperref writes exactly
that for every field it does not set. A lualatex/pdflatex document built with
hyperref and no \hypersetup{pdftitle=...} - an ordinary /add-template CV
template, not a malformed one - hits this with a working Poppler installed,
and the old message sent the reader to check their PATH when nothing was
wrong with it.

Named both causes in the raised message, the code comment above it, and the
module docstring. Behavior is unchanged: either cause still degrades to
skipped: exit 2, not exit 1, since a broken extractor is still not a broken
document.

Added test_poppler_abort_on_empty_info_string_names_that_cause_too, mocking
subprocess.run to raise CalledProcessError the way a real Poppler 26.0x abort
does (exit 1, a libc++abi out_of_range trace on stderr) rather than the
xpdf case's exit 99 - the two failures are the same exception type with
different exit codes and stderr text, so the message has to actually
distinguish them, not just catch the one class. Negative control: this test
fails against the pre-fix message with "'Poppler aborted' not found in
'...a pdftotext without -bbox is usually the xpdf build...'".

Thanks to @main-sounds-audio for the report and the isolated repro (which
field, which Poppler modes, five runs of five) that pinned this to Poppler's
own upstream regression (issue #1699, fixed in 26.05.0) rather than an
extraction-library swap.

Fixes #451
2026-09-16 06:43:20 +02:00
Ayobami Adegoke 73d52e0991 fix(verify_pdf): fold LaTeX's typographic substitutions before --contains; guard T1 fontenc for pdflatex (#385, #384) (#458)
`normalize_text()` folded whitespace only, so `--contains` compared what a
user types against what LaTeX renders. The stock CV compiled with the
documented lualatex command turns `'` into U+2019 and `--` into U+2013, so
`--contains "Master's degree"` and `--contains "2016-2024"` both reported
the keyword missing from a document that plainly contains it, through both
extractors. The documented remedy for a missing keyword is to add it, which
is the one thing the ATS section forbids.

Fold both sides at comparison time: NFC, then curly apostrophes and quotes
to ASCII, en/em dashes to `-`, no-break space to space. `--dump-text` still
writes the raw layer - that is what an ATS parses, and the date-range rule
in 05-cv-templates.md needs the raw en-dash visible there.

Separately, pdflatex without T1 font encoding stores accents decomposed
(`e` + U+0300). NFC repairs the pdftotext side of that, but pypdf reads the
same layer as `Z¨ urich` with a spacing accent, which no fold recovers.
moderncv 2.5 loads T1 itself under pdflatex; the apt-packaged 2.3.1 does
not - reproduced by compiling the template against moderncv v2.3.1 with
pdflatex (before: U+0308/U+0300 in pdftotext, `Z¨ urich` in pypdf; after:
U+00FC/U+00E8 in both). The template and the guide's preamble gain
`\ifpdftex\usepackage[T1]{fontenc}\fi`; the lualatex text layer is
byte-identical before and after.

Tests: ten new cases in test_verify_pdf.py (the fold-through and
normalize_text ones fail on the whitespace-only code) and a
test_latex_guidance.py guard that the fontenc line exists and stays inside
the pdflatex branch. framework_version 1.4.3 -> 1.4.4 on 05-cv-templates.md.

Reported and diagnosed by 9scorp4 in Discussions #385 and #384.
2026-09-14 18:30:50 +02:00
cbd8a991ab feat(layout): measure compiled PDF layout instead of eyeballing it (#378)
* feat(layout): measure compiled PDF layout instead of eyeballing it

/apply Step 5b asks for layout properties and executes none of them: they are
checked by reading the rendered page, which is exactly how they get missed.

The failure that motivates this is silent under every existing check. A moderncv
\cventry renders as a tabular, so an entry is one unbreakable block; when it does
not fit in the space left, the whole entry moves to the next page and leaves a
hole behind. The document still compiles, still reports the expected page count,
and still passes tools/verify_pdf.py. Observed in the wild at 273pt, roughly 19
blank lines, mid-page, on a CV whose visual read looked fine.

tools/verify_layout.py reports per page where the text starts and stops, bottom
whitespace as a share of page height, and the largest gap between lines, then
exits 1 on a hole over 100pt, a non-final page ending more than 25% early, body
text colliding with the page-number footer, a final page more than 35% empty, or
an entry header or section heading stranded at a page break.

Page count is deliberately not checked here - verify_pdf.py --pages already does
that, and two implementations of one rule drift. Geometry comes from Poppler
pdftotext -bbox, already a dependency; a missing Poppler exits 2 with "skipped:"
rather than failing the run.

Tests build synthetic Page/Line geometry, so the suite needs neither Poppler nor
a LaTeX toolchain and runs on the existing 3.10-3.14 matrix.

* fix(layout): survive Windows encoding and a pdftotext without -bbox

Review found two failures on the repo's primary platform:

subprocess.run(..., text=True) decoded pdftotext's UTF-8 output with the
Windows ANSI codepage and crashed on the stock cv/main_example.pdf. It now
passes encoding="utf-8" with errors="replace" at the call site, matching the
fix verify_pdf.py already carries from #369.

Git for Windows ships an xpdf-based pdftotext that shadows Poppler in a
default PATH and has no -bbox flag; it exited 99, the CalledProcessError
escaped, and the run ended in exit 1 - indistinguishable from a real layout
problem, which would send /apply chasing a phantom hole. That now routes to
the existing "skipped:" exit 2 path with a message naming the likely cause,
covered by three tests on the extractor-failure path.

Also from review:

- .claude/settings.json and security_guards.py gain the verify_layout.py
  permission entries. apply.md Step 5b runs the tool on every /apply, so
  without them every run prompts.
- The docstring and CHANGELOG no longer call Poppler a dependency
  verify_pdf.py relies on. Since #369 verify_pdf prefers pypdf and Poppler is
  the fallback; word bboxes have no pypdf equivalent, so this is the one step
  that still wants it, and that is now what the text says.
- Docstring and apply.md state that the thresholds are calibrated for the
  stock moderncv and cover.cls geometry, and that the shipped example CV
  fails the thin-final-page rule by design.
- largest_gap documents that it measures top-to-top, so a tall line inflates
  the gap by its own height - over-detection, the safe direction.
- The test module imports via tools.verify_layout like test_verify_pdf.py
  instead of sys.path.insert.

* fix(verify_layout): quote only the first stderr line in the skip message (#378)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013fqqLgQSnwgWkv98twQhHi

---------

Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 19:26:49 +02:00
8c81edc330 fix(scrape): make seen_jobs.json keys a pure function of the posting (#441)
* fix(scraper): make seen_jobs.json keys a pure function of the posting, and clean up the drift

The dedup key was prose only ("<url_or_company_title_key>"), so different
/scrape runs slugified company+title differently and the state file
accumulated two failures: keys carrying "&", "/", "," and ":" that break
the archive-folder path /apply and /outcome derive from company+role
(documents/README.md's subfolder rule exists because of exactly this),
and the same posting stored twice under two different truncations of a
long title (two Deloitte entries, one job, one URL).

tools/job_key.py makes the key a pure, deterministic function of
company+title+url: a strict allowlist slug, length-capped with a hash of
the full slug so truncation never collides across runs, and a fallback
to the portal's numeric job id when a non-Latin title slugifies to
nothing (a real prior entry, "securion_", would have collided with
every future non-Latin posting from that company).

--audit finds both failure classes in an existing seen_jobs.json without
guessing at a fix: malformed keys (real damage), a legacy three-part
company_title_location shape (harmless but not what the current rule
produces, so it silently re-duplicates on the next scrape), and
duplicate URLs. Ran it against this workspace's file and re-keyed the 15
entries it found - 7 malformed, 8 legacy-shape - verified byte-for-byte
against the pre-cleanup copy that no entry's data changed, only its key.

tests/test_job_key.py (16 tests) covers the slugify rules, the
truncation-hash behavior, both non-Latin fallback paths, and the audit
CLI's exit codes.

* fix(scrape): call the key helper from Step 4 instead of slugifying ad hoc

The helper added in the previous commit is only load-bearing if the spec
calls it. Step 4 described the key as prose ("<url_or_company_title_key>"),
which is what let each run slugify its own way. Step 4 now names the
command, and the schema shows the key's provenance.

Renumbers the trailing list item; no other behaviour in the step changes.

* docs(changelog): record the job-key rule under Unreleased

* fix(scrape): preserve dedup continuity across key rule

* changelog: note that existing seen_jobs.json entries need no migration (#441)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013fqqLgQSnwgWkv98twQhHi

---------

Co-authored-by: nox <nox@Mac.home>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:25:15 +02:00
3bf41149e0 fix(rank): move seen_jobs.json read/write off /rank's per-run cost (#395) (#425)
* fix(rank): move seen_jobs.json read/write off the state file's own critical path (#395)

/rank's Step 1 read the whole of seen_jobs.json into the conversation to
select candidates by eye, and Step 4 emitted it back to record scores.
That cost is paid on every run regardless of how many jobs are scored,
and it grows for the life of the workspace, since the file is
append-only and most stored entries are `skipped`.

tools/rank_state.py moves that traffic into code:

- `candidates` selects entries per Step 1's existing rules (status
  filter, tracker exclusion, focus filter, `--limit`/`--all` from #424)
  and projects only the fields a scoring agent needs.
- `sweep` runs rule 6's expiry pass over entries the run did not
  re-score - a stored-date comparison, no fetch, no agent - preserving
  its defensive parsing of non-ISO deadlines and its `--all`
  reversibility.
- `apply` writes scoring results back atomically and prints the
  ranked/vetoed/expired rows Step 5's report is built from, preserving
  Step 4's existing write-back rules exactly: the `location` ->
  `location_verdict` legacy migration, the deadline
  null-is-not-a-correction rule, verbatim strengths/gaps persistence,
  and idempotent re-scoring.

Step 1, Step 3's rule 6, and Step 4 now route through the tool instead
of describing a manual read/write. Nothing about scoring policy changes
- no new status, no new persisted field, no change to what counts as a
veto. The tracker stays read-only and every write is atomic (temp file
+ rename).

tests/test_rank_state.py (25 tests) covers the three subcommands
directly. The new spec-guard class in test_rank_command.py derives the
fields Step 4 must preserve from Step 2's own JSON schema block rather
than retyping them as a second list, so a future edit to that contract
is what the test reads instead of something that can drift from it.

* fix(rank): add CHANGELOG entry and remove the undefined $SCRATCHPAD reference

Two mechanical fixes from review:

- Step 4 named the results hand-off file via $SCRATCHPAD, a variable
  nothing in the repo defines - a reader following the spec literally
  has no path to substitute. Named the location in prose instead (a
  temporary file outside the repo tree, never committed) and replaced
  the shell-variable-looking path in the example command with an
  explicit placeholder.
- Added the [Unreleased] entry this change was missing; the one
  already in the diff belongs to #424.

* changelog: fold the #395 entry into the existing Fixed section

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013fqqLgQSnwgWkv98twQhHi

---------

Co-authored-by: nox <nox@Mac.home>
Co-authored-by: Mads Lorentzen <madslorentzen17@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 10:49:20 +02:00
OluwaJomilojuandClaude Sonnet 5 7f709eda57 fix(salary): require corroboration before accepting a header row (#415)
* fix(salary): require corroboration before accepting a header row

Header-row detection accepted the first row (of the first 10) where any
cell merely contained a company-pattern word - no check that the row
actually looked like a header. A source-citation row above the real
header table (standard in real Danish union/statistics exports, e.g.
"Kilde: ... opdelt efter arbejdsgiver ...") tripped it purely because
"arbejdsgiver" appeared in prose. The real header row then parsed as
data (its "Firma" cell became a bogus company), and every genuine
company silently lost all its salary data - exit 0, no warning.

A candidate row is now only accepted when a second cell also matches a
city/count/index pattern, and a sheet that ends up with zero detected
salary columns prints a warning instead of reporting success silently.

Fixes #414.

* fix(salary): require cross-cell corroboration, fall back for untyped columns

Two edge cases found in review of the corroboration fix:

- Same-cell corroboration wasn't enough: a citation sentence can pack a
  count-pattern word into the same sentence as the company-pattern one
  ("...opdelt efter arbejdsgiver, antal svar 1234"), which still passed
  the gate. Corroboration must now come from a different cell.

- The corroboration requirement itself broke sheets whose only real
  header has purely untyped salary columns (e.g. "Base pay 2025" /
  "Bonus 2025" - neither matches a known city/count/index pattern), so
  header detection found nothing at all. Falls back to the original
  any-cell-mentions-company rule when the strict pass finds no row in
  the first 10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 08:19:08 +02:00
Jakob Stender Guldberg 4c38f7ce4c fix(security): move the interview protection note to the rule that provides it (#337)
The two-line comment above `documents/interview/**` says interview prep and
experience records live there. Nothing has ever written to that directory:
/interview saves its pack to
documents/applications/<company>_<role>/interview_prep_<stage>.md, covered by
the documents/applications/** rule. `git grep documents/interview` returns
only the two declarations of the rule itself (.gitignore and
REQUIRED_IGNORE_RULES), `git log --all -- 'documents/interview*'` is empty,
and documents/README.md documents the applications path outright.

Nothing leaks - the comment is the defect, and it is the misleading kind. It
is the one dedicated, well-argued line about interview material in the
personal-data block, so an auditor checking that the framework's most
sensitive artifact is covered reads it and stops, at the only path in the
block with no writer.

The comment's description of what needs protecting was always right; only its
location was wrong. It now sits above documents/applications/**, the rule that
actually provides that protection, so a reader auditing the block finds the
reasoning attached to the rule doing the work. documents/interview/** stays -
REQUIRED_IGNORE_RULES pins it, so dropping it from .gitignore alone turns CI
red, and it is harmless defence in depth - relabelled in both files as
belt-and-braces rather than the primary guard.

The new check-ignore case in GitignorePatternBehaviorTests derives the
prep-pack path from /interview's own spec instead of hardcoding it. That
distinction is the whole value of the test: a hardcoded path pins only that
documents/applications/** still matches that shape, which security_guards.py
already catches first, and stays green if /interview moves its output -
leaving the corrected comment stale exactly the way this issue found it.
Since #329 the spec states the location in two pieces - Step 1 derives the
archive folder, Step 3 names interview_prep_<stage>.md - so the test pins both
fragments separately and composes the concrete path from them. Mutation-
verified on each half: repointing the folder at documents/prep_packs/, and
renaming the file, both fail this test while `python3 tools/security_guards.py`
still reports OK.

The class's temp-repo setup moved to setUp for the second case.

ayobamiseun reviewed the pre-rebase branch and called all three rebase hazards
in advance: the split literal, the released CHANGELOG context, and the setUp
re-merge. Reached independently here during the rebase; the review was posted
first.
2026-08-31 17:49:22 +02:00
Prasanth Kotaru 2d636c50bf security: narrow Bash(bun run:*) to the six shipped portal CLIs (#396)
The upstream template pre-approves `Bash(bun run:*)`, which auto-approves
`bun run <any file>` — arbitrary TypeScript from anywhere on disk — on every
fork. Each portal SKILL.md already declares the tight form in its own
allowed-tools; this makes settings.json agree with them.

Blast radius drops from "any file on the machine" to the repo's own CLIs,
with no new prompts in the /scrape path. tools/security_guards.py's
ALLOWED_PERMISSIONS is updated in the same commit, as its docstring requires.

Local: security_guards OK, lint_skills OK, 318 tests pass.

Claude-Session: https://claude.ai/code/session_01HHqEAQqGS2KKXASiYcrAHQ
2026-08-30 20:27:31 +02:00
Mads LorentzenandClaude Fable 5 75c15eeecc style(verify_pdf): align fallback comment indentation, restore EOF newline (#369 fixup)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 20:07:39 +02:00
sdrarunvarshan dea8140db2 feat(ats): extract PDF text with pypdf before Poppler (#369)
* feat(ats): extract PDF text with pypdf before Poppler

Lead the ATS text-layer check with pypdf (BSD, optional pip install). Fall back to pdftotext -layout -enc UTF-8. No cache directory, no installer, no AGPL pymupdf. Windows users without Poppler still get a mechanical parseability check; visual review remains the last resort.

* Update verify_pdf.py

* Update apply.md

* Update verify_pdf.py

* Update verify_pdf.py
2026-08-26 20:07:03 +02:00
Gabriel Ignacio Mensi becdc5dfd7 feat(apply,interview): cache company research to skip repeat lookups (#349)
/apply Step 3's reviewer agent and /interview Step 2 each independently
execute the Company Research Checklist (04-job-evaluation.md) for the
same company - applying to a role and later prepping for its interview
researches the company twice from scratch, same WebSearch/WebFetch cost
both times, no sharing between the two commands.

Adds a company_research/<normalized-name>.json cache (30-day TTL) that
either consumer checks before researching and writes after a fresh
pass. Defined once in 04-job-evaluation.md, next to the checklist it
mirrors, so both commands point at one source instead of restating the
schema. Does not change the verification model: 03-writing-style.md
rule 5 already treats reviewer-agent research as a lead, not a source,
requiring independent re-confirmation before any company claim ships
in a final artifact - the cache stores source URLs alongside each
fact so that re-confirmation stays cheap, but the requirement itself
is untouched and restated in both consumers.

company_research/*.json added to .gitignore and security_guards.py's
REQUIRED_IGNORE_RULES as a plain rooted pattern (not **/-prefixed):
the cache is referenced from commands, not a skill, so it resolves
against the repo root normally, unlike job_scraper/upskill's
skill-relative paths.

Pinned by tests/test_company_research_cache.py, mirroring the
spec-pinning pattern in test_rank_command.py and test_onboarding_privacy.py.
The write-back assertions for both apply.md and interview.md were
verified to actually fail against the regression they guard (the
instruction stripped, confirmed the test catches it, restored) before
being considered done - the write half is the one most likely to be
dropped silently in a future edit, since the read half is the more
obvious change to make.

framework_version bumped 1.2.4 -> 1.2.5 in 04-job-evaluation.md, the
only touched file inside the tracked skill set.
2026-08-22 11:21:34 +02:00
Mads LorentzenandClaude Opus 5 1c19f6c45f fix(salary-tools): decide number locale by last separator, pair compound headers
Review findings F7 and F8 (2026-08-19):

- parse_numeric_cell's both-separators branch always assumed European
  locale, silently turning a US "1,234.56" into 1.23456 - a 1000x
  corruption written to salary_data.json with no warning. The separator
  that appears last is now treated as the decimal separator, which also
  makes multi-group values ("1,234,567.89") parse instead of raising a
  raw float error. Single-separator ambiguity guards are unchanged.

- strip_type_patterns stripped only whole tokens, so the compound header
  "Lønindeks alle" kept its type word and could never pair with "Antal
  alle" - failing exactly for the compound-word locale COMPOUND_PATTERNS
  exists to support. It now also strips compound patterns as substrings,
  mirroring header_matches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:48:50 +02:00
Oscar Madera 621ce5ab39 fix(convert-salary-excel): reject ambiguous dot thousands separators (#326) 2026-08-14 10:51:06 +02:00
670d30ae7e feat(upstream): add commit-level triage and weekly watch for forks (#305) (#320)
Forks tracking this template face a weekly "which of these commits do I
actually care about?" question. check_upstream_updates.py answers it at the
file level (version stamps); this adds the commit-level half.

tools/upstream_triage.py walks the commits a fork is behind and splits them
into "worth reviewing" and "probably skip". Work already ported drops off on
its own via git patch-id, commits touching only files the fork removed are set
aside, and SHAs in .github/upstream-wontport.txt stay hidden. It reports and
nothing more - ready-to-run cherry-pick lines, but no merge, push, or PR,
since on a fork "applies cleanly" is not "correct".

.github/workflows/upstream-watch.yml runs it weekly into one rolling issue. It
no-ops on the upstream template (guarded, and pinned by a test) and uses only
the built-in GITHUB_TOKEN, so it can never write outside its own fork. The two
tools point at each other in their output; README, SETUP 8, and CHANGELOG
introduce them together. Tests cover patch-id matching, relevance filtering,
the won't-port list, and the workflow guard - all offline.

Co-authored-by: Angelina Lok <angelina@chattermill.io>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-11 19:11:24 +02:00
Ayobami Adegoke cfd9a9fba1 fix(security): gitignore /upskill reports at the path the skill writes them (#317)
The upskill/*.md rule is rooted, 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 at
.claude/skills/upskill/upskill/report-*.md was not ignored, and an
upskill report records the candidate's skill gaps against named
employers. **/upskill/*.md would also ignore the skill's own SKILL.md
(the directory shares the name), so the new rule pins the report-file
prefix: **/upskill/report-*.md. Added to .gitignore and
REQUIRED_IGNORE_RULES, with a check-ignore-based test pinning both
properties.
2026-08-11 19:07:21 +02:00
Muhammad Haseeb 3efc52ebd5 feat(security): hold .claude/settings.json hooks to a reviewed allowlist (#313)
check_permissions() read permissions.allow and nothing else, so a `hooks`
block in the same file passed the guard silently.

A hook is strictly more dangerous than a pre-approved permission. A
permission pre-approves something Claude may choose to do; a hook runs
unconditionally when its event fires, with no prompt and no model decision
in between. Cloning the repo and opening it is enough.

This is the vector the Shai-Hulud worm used in its August 2026 wave: a
SessionStart hook in .claude/settings.json chaining to .claude/math_init.js,
executing on session start.
https://research.jfrog.com/post/shai-hulud-is-back-august/

For a template that thousands of people are explicitly invited to fork,
that is the riskiest key in the file this guard already parses.

Follows the established pattern exactly - ALLOWED_HOOKS ships empty, since
the template has no hooks, so any addition must be allowlisted in the same
PR and is therefore explicit and reviewable.

Two details worth reviewing closely:

- The hook check runs *before* the permissions shape guards. Those guards
  return early, so a file pairing a malformed permissions block with a live
  hook would otherwise skip the hook check entirely - a fail-open. Pinned by
  test_hook_is_caught_even_when_permissions_block_is_malformed.
- _hook_commands() fails closed. Any hook layout it does not recognise
  yields a marker that cannot be in the allowlist, so an unfamiliar shape is
  rejected rather than silently skipped, rather than trusting that the
  Claude Code schema will not change.

Verified:
  - 8 new HookGuardTests cases; 14 of the suite's 26 tests fail against the
    unpatched guard, all 26 pass with it
  - injecting the real worm shape into this repo's own settings.json makes
    the guard exit 1 naming 'SessionStart:node .claude/math_init.js';
    removing it returns OK
  - lint_skills, check_framework_version, security_guards all OK;
    python3 -m unittest discover -s tests 219 passed
2026-08-10 21:18:33 +02:00
Muhammad HaseebandClaude Opus 5 a7ac6fea75 fix(security): ignore .env so a generated portal skill's API token can't be committed (#303)
/add-portal can generate a skill for a portal that only returns usable
content through a paid fetching service, and such a skill reads its API
token from the environment. Nothing stopped the `.env` holding that token
from being committed: `.gitignore` had no `.env` rule, and
`REQUIRED_IGNORE_RULES` in tools/security_guards.py did not pin one.

No shipped portal needs a credential - all six are free and
unauthenticated - so upstream has never hit this. A fork whose generated
portals do need one hits it on the first `git add -A`.

Add `.env` and `.env.*` to `.gitignore`, and pin both in
`REQUIRED_IGNORE_RULES` so the guard fails if the rule is later dropped.
No negation rule is added, so `ALLOWED_IGNORE_NEGATIONS` is untouched.

Verified:
  - `printf 'X=y' > .env && git check-ignore -v .env` -> matched
  - dropping the `.env` line makes `python3 tools/security_guards.py`
    report the missing rule and fail; restoring it returns OK
  - `lint_skills`, `check_framework_version`, `security_guards` all OK;
    `python3 -m unittest discover -s tests` 196 passed

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:49:40 +02:00
8ffe987f09 fix(robots): the gate did not fail closed on a soft 200 or an encoded Disallow (#286)
Found by an adversarial review run over the merged checker, prompted to falsify
rather than confirm. Both are pinned in tests/test_robots_check.py.

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:

    rc._fetch = lambda url, ua: ("<html>404 Not Found</html>", 200)
    rc.gate("https://x.example/jobs")
    # -> (0, 'ALLOWED - robots.txt permits this path')

A non-empty body carrying no recognised directive is now treated as unreadable.
A genuinely empty file stays allow-all per RFC 9309, so this does not
over-correct.

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.

Also adds the "--" terminator before the URL in the curl argv, plus an explicit
--max-redirs 5. gate() rebuilds the target as scheme://host/robots.txt before
calling _fetch, so the gate path was never exposed to a dash-leading URL - this
is hardening for direct callers. Three tests pin it: the terminator is present,
a dash-leading argument fails closed end to end, and gate() never passes a
caller-supplied URL through to curl.

187 tests pass.


Claude-Session: https://claude.ai/code/session_01XTtiXab1yUFF2aL4s3fVY1

Co-authored-by: kgb <kevingblackman@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 07:59:34 +02:00
Mads LorentzenandClaude Fable 5 60c735946d fix(upstream-checker): track 09-web-research.md in FRAMEWORK_FILES
The file shipped in #277 but was never added to the manifest, so forks
got no signal when it changed. Surfaced during #282 review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 06:34:00 +02:00
Oscar Madera ce60b08e81 fix(upstream-checker): report files missing from the upstream ref instead of silent OK (#282)
The per-file 'git show' failure was swallowed with a bare continue, so a file renamed or deleted upstream (or any unexpected git error) ended with a clean '[OK] All framework files are up to date' - a false all-clear.

Now the two failure modes are distinguished: files present locally but missing from the upstream ref are listed explicitly with a final [WARNING] instead of [OK], and unexpected git errors are added to the configuration errors with their stderr.

Adds UpstreamRefMissingFileTests, which simulates upstream dropping AGENTS.md while the fork keeps its copy: it fails on master and passes with the fix.
2026-08-05 06:28:23 +02:00
fcefb8150f fix(web-research): stop treating a WebFetch 403 as a dead posting (#277)
* fix(web-research): stop treating a WebFetch 403 as a dead posting

WebFetch sends a bot user agent, and many bank and corporate sites answer
with HTTP 403 while serving the same page to a browser normally. Every
command treated that as "page unavailable" and degraded silently rather
than failing loudly:

- /rank marked live postings `expired`
- /apply fell back to search snippets, or to vague cover-letter prose
- /scrape stored listing-page `#fragment` URLs, which fetch fine and
  return unrelated jobs, so every later /rank and /apply run on that
  entry failed

Adds 09-web-research.md as 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-result snippet is a lead
rather than a source.

Wires it into /apply, /rank, /interview, /outcome, /notion-sync, the
job-scraper skill, and writing-style rule 5. Bumps 03-writing-style.md
to 1.2.0; 09-web-research.md starts at 1.0.0.

Aggregator examples are given generically (LinkedIn, Indeed, national
job boards) so the guidance holds in any market.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web-research): gate the browser-header retry on robots.txt

Addresses review feedback on #277.

WebFetch identifies itself as Claude-User and honors robots.txt, so a 403 has
two very different causes and they must not be treated the same: a WAF default
on a site whose published policy allows access, or a site that has actually
declined. Retrying with browser headers in the second case circumvents the very
opt-out mechanism site owners are told they can rely on, and the core framework
cannot hold a looser standard than it asks of community forks.

The escalation now runs tools/robots_check.py before the retry. A disallow for
"*" or for "Claude-User" skips the retry entirely and goes to step 3 (find the
employer's own posting). The rule is stated plainly in 09-web-research.md 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 from testing the gate against live sites, both pinned by
tests/test_robots_check.py (15 offline cases):

- The WAF usually blocks robots.txt too. privatebank.barclays.com returns 403
  on the policy file to Claude-User and 200 to a browser, so a naive gate would
  block the retry on exactly the sites the retry is for. The checker reads the
  policy as a browser when the honest request is refused, then obeys it
  strictly - a policy you are prevented from reading cannot be honored, and
  robots.txt is not the protected resource.
- urllib.robotparser cannot be used. It ends a record at a blank line and
  matches rules in file order, so Barclays' real file (blank lines between
  "User-agent: *" and its rules, "Allow: /" before "Disallow: /cs/") reads as
  everything-allowed. That fails open, in the one direction that matters. The
  checker implements RFC 9309 longest-match instead, with ties resolved to
  Disallow rather than Allow.

Verified live: barclays /careers/ allowed and /cs/ blocked, ubs.com allowed,
jobup.ch /api/ blocked while /en/jobs/ stays allowed. 09-web-research.md
1.0.0 to 1.1.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: kgb <kevingblackman@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:36:55 +02:00
Oscar Madera 0433f3e332 fix(check_upstream_updates): compare template repo URL case-insensitively (#273)
GitHub serves repo paths case-insensitively, so a direct clone from https://github.com/madslorentzen/ai-job-search (lowercased) triggered the fork-vs-self warning even though origin is the template repo itself. Lowercase both sides of the check.

New test clones with a lowercased URL: fails on the previous check, passes with this fix.
2026-08-02 21:15:28 +02:00
Ayobami Adegoke 4f7f11ef4e fix(salary): parse localized numeric strings (#272) 2026-08-02 21:15:09 +02:00
Ayobami Adegoke 72f1f3d608 test(security): require personal output ignore rules (#271) 2026-08-01 22:21:20 +02:00
Oscar Madera 72bbe00529 fix(check_upstream_updates): warn when check falls back to a fork's own origin (#265)
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.
2026-08-01 22:00:49 +02:00