193 Commits
Author SHA1 Message Date
Mariano Gobea Alcoba 24db6b3b59 fix(specs): qualify seen_jobs.json as job_scraper/seen_jobs.json in /rank, /notion-sync and /upskill (#617)
Follow-up to #608 at the maintainer's invitation. /scrape already names the full
path; the three readers still used the bare filename, which an agent can resolve
to the repo root (outside the **/job_scraper/seen_jobs.json ignore rule).
The guard test now covers all four specs; test_notion_sync_command pins the
qualified phrase. 04-job-evaluation.md is left alone on purpose: forks
personalize it, and a one-word fix there would force a framework_version bump.
2026-10-18 08:09:09 +02:00
Rudy Mizrahi Celekli 176b675080 fix(framework-version): distinguish marker changes from YAML formatting (#578)
* fix(framework-version): distinguish marker changes from YAML formatting

* fix(framework-version): normalize Windows paths for Git base reads
2026-10-18 07:36:13 +02:00
Rudy Mizrahi Celekli d590aa1b10 fix(reset): clear document entries while preserving scaffolds (#580)
* fix(reset): clear document entries while preserving scaffolds

* fix(reset): preserve scaffolds across Bash versions

* docs(reset): keep document inventory before presentation heading
2026-10-18 07:33:41 +02:00
Rudy Mizrahi Celekli 894e3917ba fix(salary): preserve Unicode company identity in lookups (#570)
* fix(salary): preserve Unicode company identity in lookups

* fix(salary): restore accent folding after Nordic spellings

Address MadsLorentzen review feedback on #570: keep Unicode identity normalization and restore ASCII-typed accented-name controls on the documented CLI path.
2026-10-18 07:28:42 +02:00
Rudy Mizrahi Celekli 5a29fdc711 fix(upstream): preserve selected refs in companion commands (#591)
* fix(upstream): preserve selected refs in companion commands

* fix(upstream): quote companion command arguments

* docs(changelog): record fix for #591
2026-10-18 07:26:10 +02:00
Rudy Mizrahi Celekli 239dad775b fix(upstream): distinguish new paths from fork deletions (#585)
* fix(upstream): distinguish new paths from fork deletions

* docs(changelog): record fix for #585
2026-10-18 07:24:01 +02:00
Rudy Mizrahi Celekli a748d89fe6 fix(upstream): flag incomplete framework comparisons (#583)
* fix(upstream): flag incomplete framework comparisons

* docs(changelog): record fix for #583
2026-10-18 07:21:32 +02:00
Rudy Mizrahi Celekli 991bda5dd2 fix(salary): reject non-finite workbook numbers (#572)
* fix(salary): reject non-finite workbook numbers

* docs(changelog): record fix for #572
2026-10-18 07:19:24 +02:00
Rudy Mizrahi Celekli ef4d12711c fix(salary): parse Unicode whitespace grouping (#592)
* fix(salary): parse Unicode whitespace grouping

* docs(changelog): record fix for #592
2026-10-18 07:16:55 +02:00
Rudy Mizrahi Celekli 8cc2efbf21 fix(salary): prefer exact workbook identity headers (#571)
* fix(salary): prefer exact workbook identity headers

* docs(changelog): record fix for #571
2026-10-18 07:14:26 +02:00
Rudy Mizrahi Celekli 652508a929 fix(salary): skip chart-only workbook tabs (#590)
* fix(salary): skip chart-only workbook tabs

* docs(changelog): record fix for #590
2026-10-18 07:11:57 +02:00
Rudy Mizrahi Celekli b7be711ca5 fix(salary): distinguish absent indexes from suppression (#584)
* fix(salary): distinguish absent indexes from suppression

* docs(changelog): record fix for #584
2026-10-18 07:09:27 +02:00
Rudy Mizrahi Celekli 9940a84037 fix(salary): skip whitespace-only workbook company rows (#576)
* fix(salary): skip whitespace-only workbook company rows

* docs(changelog): record fix for #576
2026-10-18 07:07:18 +02:00
Rudy Mizrahi Celekli 0b22e55e8f fix(salary): validate metadata before rendering lookups (#575)
* fix(salary): validate metadata before rendering lookups

* docs(changelog): record fix for #575
2026-10-18 07:05:10 +02:00
Mads LorentzenandClaude Fable 5.1 5d9e0977f0 fix(salary): convert the IDA workbook again ("Firmanavn og by" header) and list unreachable in the scrape schema (#615)
"firma" joins COMPOUND_PATTERNS so the glued Danish header "Firmanavn og by"
matches again; whole-token matching since #75 skipped both sheets of the
repository's own demonstration workbook with "Could not find header row".
Every other workbook converts byte-identically. Also folds the `unreachable`
status from #564 into the seen_jobs.json schema enum in the scrape spec.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-17 20:12:49 +02:00
8123d88f30 fix(scrape): use the full job_scraper/seen_jobs.json path in every step of the spec (#608)
* fix(scrape): use the full job_scraper/seen_jobs.json path in every step of the spec

Step 0 reads job_scraper/seen_jobs.json but Steps 1-4 and the rules used the bare
filename. Step 4 is where the file is written, so an agent following it literally
can create seen_jobs.json in the repo root, outside the **/job_scraper/seen_jobs.json
ignore rule, where it gets committed with the user's scrape history.

Adds a spec test that rejects bare references; relaxes the existing dedup-continuity
regex to accept the qualified path.

* docs(changelog): entry for #608

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-17 20:10:20 +02:00
822d19a2ba fix(salary): accept BOM-prefixed UTF-8 data files (#602)
* fix(salary): accept BOM-prefixed UTF-8 data files

Fixes #594. Thanks to @rahul05ranjan for reporting the failure and identifying the decoder change.

* docs(changelog): entry for #602

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-17 20:06:36 +02:00
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 2697fa46fb fix(upskill,notion-sync): join tracker rows to ranked entries on title, the field entries carry (#614)
Both specs said to match "on company + role" with "casefold + strip on
both fields", but a seen_jobs.json entry has title and no role key (the
job-scraper schema), so read literally the lookup never matched: /upskill
then weighted the same job twice, once as a tracker row and once as a
ranked entry - the double-count its dedupe step exists to prevent - and
/notion-sync split one job into two rows with different Keys.
tools/rank_state.py already does the cross-name join (tracker role
against entry title); both specs now say so.

Two derived tests read the entry schema and require the join wording to
name title for the entry and role for the tracker; they fail on master.
2026-10-17 20:01:34 +02:00
Ayobami Adegoke 709339758a fix(reset): keep the Languages table header under Identity in the candidate-profile skeleton (#613)
04-job-evaluation.md's Language Gate and job-scraper/search-queries.md's
query-language generation both read "your Languages table" from
01-candidate-profile.md, but the skeleton /reset wrote left "## Identity"
empty, so after a reset /rank and /scrape looked for a table the file no
longer had.

Carry "### Languages" and the table's header and separator rows in the
skeleton. The test derives both rows from the shipped file and pins that
the placeholder data row is not reintroduced; it fails on master.

Follow-up noted on #532.
2026-10-17 19:58:20 +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
Ahmed e2b966d0e7 fix(apply,rank): route matching portal URLs to installed CLI detail before WebFetch (#535) (#562)
* fix(apply,rank): route matching portal URLs to installed CLI detail before WebFetch (#535)

When given a URL belonging to an installed portal (e.g. LinkedIn, Jobnet), WebFetch frequently encounters login walls, bot-detection blocks, or 403s. Installed portal CLIs under .agents/skills/ already provide a dedicated detail command that fetches clean posting text and structured metadata directly from board APIs.

- In .claude/commands/apply.md Step 0, invoke the matching installed portal CLI's detail command first, falling back to WebFetch on failure.

- In 09-web-research.md, place the installed portal CLI detail command as the first rung of the escalation order, bumping framework_version to 1.1.2.

- In .claude/commands/rank.md Step 2, align scoring agents to use the portal CLI's detail command before WebFetch.

- In tests/test_apply_host_check.py, add spec and resolution tests ensuring portal CLI detail precedence is strictly asserted and verified.

* fix(apply,rank): route matching portal URLs to installed CLI detail before WebFetch (#535)

When given a URL belonging to an installed portal, WebFetch frequently encounters login walls, bot-detection blocks, or 403s. Installed portal CLIs under .agents/skills/ already provide a dedicated detail command that fetches clean posting text and structured metadata directly from board APIs.

- In .claude/commands/apply.md Step 0, invoke the matching installed portal CLI's detail command first, falling back to WebFetch on failure. Derive matching portal skills from board hosts in cli/src and url-reference.md.

- In 09-web-research.md, place the installed portal CLI detail command as the first rung of the escalation order, update subsequent step references, and bump framework_version to 1.1.2.

- In .claude/commands/rank.md Step 2, align scoring agents to use the portal CLI's detail command before WebFetch.

- In CHANGELOG.md, record the framework_version bump (1.1.1 -> 1.1.2) under [Unreleased] / ### Changed.

- In tests/test_apply_host_check.py, add spec and dynamic resolution tests ensuring portal CLI detail precedence is strictly asserted and verified across all discovered installed skills.
2026-10-15 18:12:33 +02:00
52f0b2719b Fix fork-skip guards for local test runs (#524 rebased, ssh:// origin form folded in) (#563)
* Fix fork-skip guards for local test runs

The fork skip read GITHUB_REPOSITORY with an upstream fallback, so local runs on a fork always ran the upstream-only guards. Share one helper that checks the origin remote when the variable is unset. Fixes #496.

* test: accept the ssh:// origin form in the upstream-checkout helper

An upstream clone whose origin is ssh://git@github.com/MadsLorentzen/ai-job-search(.git)
must keep running the placeholder guards; the helper only knew the https and
scp-style forms. Folded in at maintainer rebase per the #524 review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Omarinho12345 <omarottelli720@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 11:59:57 +02:00
Oscar Madera e475d355ae fix(add-portal): include .agents prefix in generated SKILL.md allowed-tools (#545) (#548) 2026-10-15 11:50:57 +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 f695aa19de fix(cv-templates): align geometry scale in 05-cv-templates.md with main_example.tex (#553) (#554) 2026-10-15 11:45:14 +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 eaa73e55ab fix(reset): keep the framework_version frontmatter on the two files /reset profile rewrites (#532)
The full-file skeletons for 01-candidate-profile.md and
02-behavioral-profile.md started at the "#" title, while the shipped
files open with a "---" block carrying framework_version. Applied as
written, one /reset profile left both files failing
tools/check_framework_version.py ("missing 'framework_version' in
frontmatter") and invisible to tools/check_upstream_updates.py, which
records a configuration error for a file with no local version - the
fork update path every fork is sent through.

Say to keep the frontmatter block and replace only what follows, with a
note under "Profile reset" that the marker is framework data, never
candidate data. The version is not hardcoded into the skeleton, where it
would go stale on the next bump. A derived test applies each skeleton
the way the step says and asserts the shipped file's version still
parses out unchanged; it fails on master for both files.
2026-10-15 11:30:49 +02:00
Ayobami Adegoke a011214d60 fix(reset): target the headings the shipped skill files actually have (#519)
* fix(reset): target the headings the shipped skill files actually have

Two of /reset profile's Step 3 targets had drifted from the files they
rewrite.

The 05-cv-templates.md step anchored on a section beginning
"**Profile statement templates", a line the guide has never contained
(its heading is "**Create 2-3 profile statement templates for your main
role types:**"), so the wipe found nothing: the user's role-tailored
profile statements, and any *[Used for: ...]* statements /setup Path A
extracted from archived applications, survived a reset that reported
success.

The 02-behavioral-profile.md skeleton used headings the shipped file
does not have ("Strongest Behavioral Traits", "How I Work Best", a
truncated "Growth Areas") and dropped "## Core Behavioral Drives" - the
assessment table /setup collects and /interview reads - so one reset
left the file's headings permanently different from every other fork's.

Anchor on the real heading and re-emit it in the replacement; carry the
shipped headings in order in the skeleton. 01-candidate-profile.md and
07-interview-prep.md were already correct. Two new tests derive both
checks from the shipped files, so a renamed heading fails until /reset
follows it; both fail on master.

* test(reset): place the heading guards above the __main__ guard so a direct run sees them

python tests/test_reset_command.py ran only the five old tests; unittest
discover imports the module and saw all seven, which is why CI was green.
Also drop the CHANGELOG's claim that /setup and /interview name the Core
Behavioral Drives table - neither does.
2026-10-08 21:23:59 +02:00
Ayobami Adegoke 8dbc941642 test(apply): derive the host-check's installed portals from the skills tree (#528)
SHIPPED_PORTAL_HOSTS was six names typed into the test and nothing tied
it to anything: a portal added by /add-portal fetches from a host the
copy never heard of, so its postings reached /apply classified
"unverified" while the suite stayed green, and a dropped portal left a
stale name behind.

apply.md rule 1 defines an installed portal as "any configured job portal
in .agents/skills/", so that tree is the source: the host literals in
each *-search/cli/src/**/*.ts (one BASE_URL per shipped CLI, www. dropped
so apex and subdomain both match). Discovery is by installation, not by
the enabled: flag - an installed-but-disabled board is still a legitimate
source for a pasted posting. classify_posting_host's default is the
discovered set, and a new class asserts every installed portal has a
discoverable host that classifies as installed_portal at the apex, under
www. and under a subdomain. The ATS apex list stays the fixed set it is.

Reported by Holo-Eter in #497; shape per the maintainer's note there.
2026-10-08 21:21:28 +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 760bbc34cf fix(expand,setup): append behavioral findings to sections 02-behavioral-profile.md actually has (#520)
Both commands told the model to add to "Strongest Behavioral Traits" and
"How I Work Best" / "How [Candidate] Works Best" while also saying "match
existing structure", but the shipped file's headings are "Strongest
Behaviors" and "How You Work Best", so an inferred trait either opened a
duplicate near-identical section or was dropped.

The companion to #519, which fixed the same drift in /reset's skeleton.
Both bullets now name the shipped headings, and a derived test in each
command's suite reads the quoted section names and requires each to be a
## heading of the shipped file; both fail on master.
2026-10-08 21:18:08 +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
Ayobami Adegoke f9ef82f169 fix(rank): sweep deadlines when no candidates need scoring (#494)
* fix(rank): sweep deadlines when no candidates need scoring

* changelog: record the /rank empty-candidate sweep under [Unreleased] / Fixed
2026-10-02 20:00:16 +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
Yang 97e2575795 fix(tests): escape the regex in test_verify_pdf's docstring and compile-check tests/*.py (#503)
FindNonAsciiDateRangesTests' docstring (from #498) quotes the regex `\s*`
in a non-raw string. `\s` is an invalid escape sequence, so Python 3.12+
prints a SyntaxWarning for it (CPython gh-98401) when
tests/test_verify_pdf.py is compiled. It is in this repo's own CI log
for a45bbd1 on 3.12, 3.13 and 3.14, at tests/test_verify_pdf.py:75, while
the run stays green. Under `-W error::SyntaxWarning` the module fails to
import and the whole test_verify_pdf suite drops out of the run.

Escaped in the docstring. ToolsCompileWithoutWarnings (#480) only compiled
tools/*.py, which is why this got through; it now compiles tests/*.py too.
With the unescaped docstring it fails naming tests/test_verify_pdf.py, and
it passes with the fix.

Verification on Python 3.10, 3.11, 3.12, 3.13 and 3.14, from a clean
bytecode cache: `python -W error::SyntaxWarning -m unittest discover -s
tests -t .` OK (501 tests, the same count as master),
`tools/lint_skills.py`, `tools/check_framework_version.py`,
`tools/security_guards.py`.
2026-09-29 20:15:19 +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