fix(specs): settle every workflow spec on python3, one Windows note at first use (#678)

* fix(specs): settle every workflow spec on python3 with one Windows note at first use

* fix(specs): finish python3 in the specs /apply reads; Windows note says run as python

- 04-job-evaluation (salary lookup), 05-cv-templates (verify_pdf x2) and
  CLAUDE.md now spell python3 like apply.md; framework_version bumped on 04
  (1.2.7) and 05 (1.4.8), each with its own Windows note at first use.
- The note now covers Windows generally: python3 is either the exit-49 Store
  stub or a separate Store Python without pip's packages, so run as python.
- tests/test_python_command_specs.py globs commands, skills and agents
  instead of naming four files.
- CHANGELOG entry matches the final scope.

* changelog: place the #678 entry under [Unreleased]

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

---------

Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 08:27:21 +02:00
1 parent d621ebe635
commit 8fb34c6cb7
11 files changed
+168 -17

No files matched your search

+9 -7
View File
@@ -44,8 +44,10 @@ Read the evaluation framework:
Using the framework from `04-job-evaluation.md`, evaluate the job posting against the candidate's profile. If the salary lookup tool is configured, run: Using the framework from `04-job-evaluation.md`, evaluate the job posting against the candidate's profile. If the salary lookup tool is configured, run:
> **Windows:** run these tools as `python`, not `python3`. A python.org install ships no `python3`, so that name reaches the Microsoft Store alias, which either exits 49 with no output (#669) or starts a separate Store Python without the packages `pip` installed (such as pypdf). `.claude/settings.json` allows both spellings.
```bash ```bash
python salary_lookup.py "<Company Name>" --json python3 salary_lookup.py "<Company Name>" --json
``` ```
If the posting specifies a city, add `--city "<City>"` to narrow results. Parse the JSON output and include the salary benchmark in the evaluation. If the tool is not configured or returns an error, skip the salary benchmark. If the posting specifies a city, add `--city "<City>"` to narrow results. Parse the JSON output and include the salary benchmark in the evaluation. If the tool is not configured or returns an error, skip the salary benchmark.
@@ -256,10 +258,10 @@ If either compile fails, fix the error and re-compile until clean.
**Measure first, then look.** A visual read catches gross breakage but cannot tell you that a page is 40% empty, and the failure below survives both a clean compile and a correct page count: **Measure first, then look.** A visual read catches gross breakage but cannot tell you that a page is 40% empty, and the failure below survives both a clean compile and a correct page count:
```bash ```bash
python tools/verify_pdf.py cv/main_<company>_<role>.pdf --pages 2 python3 tools/verify_pdf.py cv/main_<company>_<role>.pdf --pages 2
python tools/verify_pdf.py cover_letters/cover_<company>_<role>.pdf --pages 1 python3 tools/verify_pdf.py cover_letters/cover_<company>_<role>.pdf --pages 1
python tools/verify_layout.py cv/main_<company>_<role>.pdf python3 tools/verify_layout.py cv/main_<company>_<role>.pdf
python tools/verify_layout.py cover_letters/cover_<company>_<role>.pdf python3 tools/verify_layout.py cover_letters/cover_<company>_<role>.pdf
``` ```
The two `--pages` lines are the page-count check: exactly 2 pages for the CV and exactly 1 for the cover letter (the hard limits in `05-cv-templates.md` and `06-cover-letter-templates.md`), exit 1 otherwise. With a custom template active, substitute its declared **Page limit** from the `ACTIVE-TEMPLATE` block. Nothing else runs this check - `verify_layout.py` deliberately leaves page count to it, and Step 5d's extraction call passes no `--pages` - so if these lines are skipped, the page budget is enforced by nothing but the visual read below. The two `--pages` lines are the page-count check: exactly 2 pages for the CV and exactly 1 for the cover letter (the hard limits in `05-cv-templates.md` and `06-cover-letter-templates.md`), exit 1 otherwise. With a custom template active, substitute its declared **Page limit** from the `ACTIVE-TEMPLATE` block. Nothing else runs this check - `verify_layout.py` deliberately leaves page count to it, and Step 5d's extraction call passes no `--pages` - so if these lines are skipped, the page budget is enforced by nothing but the visual read below.
@@ -301,12 +303,12 @@ Do not proceed to Step 6 until both PDFs pass inspection.
An ATS parser reads the PDF's embedded **text layer**, not the rendered page — a CV that passed visual inspection can still extract as garbage (icon glyphs where the contact details should be, scrambled reading order in multi-column layouts). This step verifies what a parser actually sees. It applies to the **CV only**; cover letters rarely go through keyword screening. An ATS parser reads the PDF's embedded **text layer**, not the rendered page — a CV that passed visual inspection can still extract as garbage (icon glyphs where the contact details should be, scrambled reading order in multi-column layouts). This step verifies what a parser actually sees. It applies to the **CV only**; cover letters rarely go through keyword screening.
**Availability check:** extract with `python tools/verify_pdf.py` (tries **pypdf** first — BSD, `pip install pypdf` — then Poppler `pdftotext`). If both are missing, print a one-line warning that the mechanical parse check is skipped, do the keyword-coverage check (item 3 below) against your visual Read of the PDF instead, and note the degraded mode in the Step 6 report. Same graceful-skip pattern as the salary lookup. If a documented fallback still shells out to `pdftotext -layout`, keep the `-enc UTF-8` flag: Xpdf-based builds default to Latin-1 output, and without it a correct non-ASCII CV fails the replacement-character check below. **Availability check:** extract with `python3 tools/verify_pdf.py` (tries **pypdf** first — BSD, `pip install pypdf` — then Poppler `pdftotext`). If both are missing, print a one-line warning that the mechanical parse check is skipped, do the keyword-coverage check (item 3 below) against your visual Read of the PDF instead, and note the degraded mode in the Step 6 report. Same graceful-skip pattern as the salary lookup. If a documented fallback still shells out to `pdftotext -layout`, keep the `-enc UTF-8` flag: Xpdf-based builds default to Latin-1 output, and without it a correct non-ASCII CV fails the replacement-character check below.
**1. Extract the text layer:** **1. Extract the text layer:**
```bash ```bash
python tools/verify_pdf.py cv/main_<company>_<role>.pdf --ascii-dates --dump-text cv/main_<company>_<role>.txt python3 tools/verify_pdf.py cv/main_<company>_<role>.pdf --ascii-dates --dump-text cv/main_<company>_<role>.txt
``` ```
`--ascii-dates` is the date-range check from `05-cv-templates.md` ("Date fields must be ASCII ranges"): it exits 1 if the raw text layer has a year joined to a Unicode dash - the en-dash LaTeX makes from `--`, which a Workday import dropped together with the date - naming each hit and its code point. It reads the raw layer, not the folded comparison `--contains` uses, which is why `--contains "2016-2024"` cannot catch this. The dump is written before the check runs, so a failure still leaves the `.txt`. Fix a hit in the `<CV_EXT>` date argument (a single ASCII hyphen), then re-run 5a-5c and re-extract. `--ascii-dates` is the date-range check from `05-cv-templates.md` ("Date fields must be ASCII ranges"): it exits 1 if the raw text layer has a year joined to a Unicode dash - the en-dash LaTeX makes from `--`, which a Workday import dropped together with the date - naming each hit and its code point. It reads the raw layer, not the folded comparison `--contains` uses, which is why `--contains "2016-2024"` cannot catch this. The dump is written before the check runs, so a failure still leaves the `.txt`. Fix a hit in the `<CV_EXT>` date argument (a single ASCII hyphen), then re-run 5a-5c and re-extract.
+2
View File
@@ -26,6 +26,8 @@ Follow these steps **in order**.
Never read `job_scraper/seen_jobs.json` into the conversation. It holds every job the workspace has ever seen - most of it `skipped` - while a run only ever touches the handful of entries being scored, so a manual read costs the whole backlog on every run and grows for the life of the workspace. Selecting candidates is a query, so run the query: Never read `job_scraper/seen_jobs.json` into the conversation. It holds every job the workspace has ever seen - most of it `skipped` - while a run only ever touches the handful of entries being scored, so a manual read costs the whole backlog on every run and grows for the life of the workspace. Selecting candidates is a query, so run the query:
> **Windows:** run these tools as `python`, not `python3`. A python.org install ships no `python3`, so that name reaches the Microsoft Store alias, which either exits 49 with no output (#669) or starts a separate Store Python without the packages `pip` installed (such as pypdf). `.claude/settings.json` allows both spellings.
```bash ```bash
python3 tools/rank_state.py candidates --limit 10 # add --all / --focus "<text>" per Step 0 python3 tools/rank_state.py candidates --limit 10 # add --all / --focus "<text>" per Step 0
``` ```
@@ -1,5 +1,5 @@
--- ---
framework_version: 1.2.6 framework_version: 1.2.7
--- ---
# Job Evaluation Framework # Job Evaluation Framework
@@ -124,8 +124,11 @@ Does this role advance career goals and contain tasks that energize?
### 6. Salary Benchmark (Optional) ### 6. Salary Benchmark (Optional)
If the salary lookup tool is configured (`salary_data.json` exists), look up the company: If the salary lookup tool is configured (`salary_data.json` exists), look up the company:
> **Windows:** run these tools as `python`, not `python3`. A python.org install ships no `python3`, so that name reaches the Microsoft Store alias, which either exits 49 with no output (#669) or starts a separate Store Python without the packages `pip` installed (such as pypdf). `.claude/settings.json` allows both spellings.
``` ```
python salary_lookup.py "<Company Name>" --json python3 salary_lookup.py "<Company Name>" --json
``` ```
If a city is known from the posting, add `--city "<City>"` to narrow results. If a city is known from the posting, add `--city "<City>"` to narrow results.
@@ -1,5 +1,5 @@
--- ---
framework_version: 1.4.7 framework_version: 1.4.8
--- ---
# CV Templates and Tailoring Guide # CV Templates and Tailoring Guide
@@ -273,8 +273,10 @@ Restore the highest-relevance item that was previously cut — a CV that ends mi
Most employers run CVs through an ATS before a human sees them, and the ATS reads the PDF's embedded **text layer**, not the rendered page. A CV can pass visual inspection and still extract as garbage. After the layout passes the compile-and-inspect loop, verify the text layer: Most employers run CVs through an ATS before a human sees them, and the ATS reads the PDF's embedded **text layer**, not the rendered page. A CV can pass visual inspection and still extract as garbage. After the layout passes the compile-and-inspect loop, verify the text layer:
> **Windows:** run these tools as `python`, not `python3`. A python.org install ships no `python3`, so that name reaches the Microsoft Store alias, which either exits 49 with no output (#669) or starts a separate Store Python without the packages `pip` installed (such as pypdf). `.claude/settings.json` allows both spellings.
```bash ```bash
python tools/verify_pdf.py cv/main_<company>_<role>.pdf --dump-text cv/main_<company>_<role>.txt python3 tools/verify_pdf.py cv/main_<company>_<role>.pdf --dump-text cv/main_<company>_<role>.txt
``` ```
Extraction tries **pypdf** first (`pip install pypdf`, BSD license), then Poppler `pdftotext`. If a fallback still uses `pdftotext -layout`, it must also pass `-enc UTF-8`: Xpdf-based builds default to Latin-1, which makes every non-ASCII character in a perfectly good CV read back as a replacement character. If neither extractor is available, skip the mechanical check with a warning and rely on the visual PDF read for keyword coverage. Extraction tries **pypdf** first (`pip install pypdf`, BSD license), then Poppler `pdftotext`. If a fallback still uses `pdftotext -layout`, it must also pass `-enc UTF-8`: Xpdf-based builds default to Latin-1, which makes every non-ASCII character in a perfectly good CV read back as a replacement character. If neither extractor is available, skip the mechanical check with a warning and rely on the visual PDF read for keyword coverage.
@@ -310,7 +312,7 @@ Two independent causes, both easy to avoid:
Where a genuine range exists, use it even when a single year would be factually accurate - a degree written `1995` is true but imports worse than `1992-1995`. Do not invent a start date you do not have; a lone graduation year is fine, just expect it to be typed in by hand. Where a genuine range exists, use it even when a single year would be factually accurate - a degree written `1995` is true but imports worse than `1992-1995`. Do not invent a start date you do not have; a lone graduation year is fine, just expect it to be typed in by hand.
**Cause 1 is mechanical in step 5d**: `python tools/verify_pdf.py cv/main_<company>_<role>.pdf --ascii-dates` scans the raw text layer for a year joined to a Unicode dash and exits 1 naming each hit with its code point (`U+2013 in 'Role Title 2016–2024'`). It deliberately reads the raw layer: the fold that lets `--contains "2016-2024"` match what the template renders maps that same en-dash back to `-`, so `--contains` can never see this. A year on either side of the dash is enough, so `Mar 2016 – Jul 2016` and `2016 – Present` are caught too; a numeric range with no year (`EUR 600k–1M`) is left alone. **Cause 2 stays a read-through check**: after extracting the text layer, confirm every experience entry shows a start *and* an end. Because both failures are silent and invisible in the PDF, the candidate otherwise discovers them only while filling in the application form. **Cause 1 is mechanical in step 5d**: `python3 tools/verify_pdf.py cv/main_<company>_<role>.pdf --ascii-dates` scans the raw text layer for a year joined to a Unicode dash and exits 1 naming each hit with its code point (`U+2013 in 'Role Title 2016–2024'`). It deliberately reads the raw layer: the fold that lets `--contains "2016-2024"` match what the template renders maps that same en-dash back to `-`, so `--contains` can never see this. A year on either side of the dash is enough, so `Mar 2016 – Jul 2016` and `2016 – Present` are caught too; a numeric range with no year (`EUR 600k–1M`) is left alone. **Cause 2 stays a read-through check**: after extracting the text layer, confirm every experience entry shows a start *and* an end. Because both failures are silent and invisible in the PDF, the candidate otherwise discovers them only while filling in the application form.
## Page Budget - Hard 2-Page Limit ## Page Budget - Hard 2-Page Limit
@@ -1,5 +1,5 @@
--- ---
framework_version: 1.1.5 framework_version: 1.1.6
--- ---
# Web Research and Fetching # Web Research and Fetching
@@ -35,6 +35,8 @@ Do **not** respond to a 403 by softening the cover letter to vague generalities,
The retry command below runs this check itself and refuses to fetch when it fails - there is no flag to skip it. The standalone checker is still there for diagnosing a refusal: The retry command below runs this check itself and refuses to fetch when it fails - there is no flag to skip it. The standalone checker is still there for diagnosing a refusal:
> **Windows:** run these tools as `python`, not `python3`. A python.org install ships no `python3`, so that name reaches the Microsoft Store alias, which either exits 49 with no output (#669) or starts a separate Store Python without the packages `pip` installed (such as pypdf). `.claude/settings.json` allows both spellings.
```bash ```bash
python3 tools/robots_check.py '<URL>' python3 tools/robots_check.py '<URL>'
``` ```
@@ -52,7 +54,7 @@ Two details worth knowing, both covered by `tests/test_robots_check.py`:
python3 tools/fetch_page.py '<URL>' --out "${SCRATCHPAD:?set this to the session scratchpad directory from your system prompt}/page.html" --text python3 tools/fetch_page.py '<URL>' --out "${SCRATCHPAD:?set this to the session scratchpad directory from your system prompt}/page.html" --text
``` ```
One command does the whole retry: it runs the `robots.txt` gate above (exit `1` and no fetch when the policy says no), GETs the page with browser headers, saves the body to `--out`, prints `HTTP <code> size=<bytes> saved=<path>`, and with `--text` prints the tag-stripped text (script, style, noscript, svg and comments removed, entities unescaped) capped at `--max-chars` (default 6000). It is GET-only with a fixed argv, http(s) only, bounded to 45 s, 5 redirects and 10 MB, and it refuses an `--out` inside the repo - fetched content is untrusted and belongs in the scratchpad. That fixed shape is why the workflow pre-approves `python tools/fetch_page.py` and not `curl`. One command does the whole retry: it runs the `robots.txt` gate above (exit `1` and no fetch when the policy says no), GETs the page with browser headers, saves the body to `--out`, prints `HTTP <code> size=<bytes> saved=<path>`, and with `--text` prints the tag-stripped text (script, style, noscript, svg and comments removed, entities unescaped) capped at `--max-chars` (default 6000). It is GET-only with a fixed argv, http(s) only, bounded to 45 s, 5 redirects and 10 MB, and it refuses an `--out` inside the repo - fetched content is untrusted and belongs in the scratchpad. That fixed shape is why the workflow pre-approves `python3 tools/fetch_page.py` and not `curl`.
The HTTP status is information, not a verdict: a `403` body still tells you what the site answered. Only exit `2` (unsupported scheme, transport failure, oversized response) means nothing was retrieved. The HTTP status is information, not a verdict: a `403` body still tells you what the site answered. Only exit `2` (unsupported scheme, transport failure, oversized response) means nothing was retrieved.
+2
View File
@@ -143,6 +143,8 @@ For each new job, do a rapid fit check (NOT the full evaluation from `04-job-eva
1. Derive each entry's key with the helper, never by slugifying in the moment: 1. Derive each entry's key with the helper, never by slugifying in the moment:
> **Windows:** run these tools as `python`, not `python3`. A python.org install ships no `python3`, so that name reaches the Microsoft Store alias, which either exits 49 with no output (#669) or starts a separate Store Python without the packages `pip` installed (such as pypdf). `.claude/settings.json` allows both spellings.
```bash ```bash
python3 tools/job_key.py --company "<company>" --title "<title>" --url "<url>" python3 tools/job_key.py --company "<company>" --title "<title>" --url "<url>"
``` ```
+22
View File
@@ -220,6 +220,28 @@ per-file diff commands.
that code page (e.g. Polish, Cyrillic, or CJK) raised `UnicodeEncodeError` when writing stdout. that code page (e.g. Polish, Cyrillic, or CJK) raised `UnicodeEncodeError` when writing stdout.
Similarly, `robots_check.py` now guarantees UTF-8 standard streams across platforms. Similarly, `robots_check.py` now guarantees UTF-8 standard streams across platforms.
- **Every workflow spec invokes Python as `python3`, with a single Windows note at first use**
(#678, fixes #669, `.claude/commands/apply.md`, `.claude/commands/rank.md`,
`.claude/skills/job-scraper/SKILL.md`,
`.claude/skills/job-application-assistant/04-job-evaluation.md`,
`.claude/skills/job-application-assistant/05-cv-templates.md`,
`.claude/skills/job-application-assistant/09-web-research.md`, `CLAUDE.md`,
`tests/test_python_command_specs.py`) - the specs split between spellings:
`/apply` and the skill files it reads (`04-job-evaluation`, `05-cv-templates`)
plus `CLAUDE.md` ran bare `python salary_lookup.py` / `python tools/verify_*.py`
while `/rank`, `/scrape` and `09-web-research` ran `python3 ...`, so one
`/apply` run saw both spellings for the same commands. Every command now reads
`python3`, and each spec that runs a tool carries one Windows note at its
first use: run the tools as `python` there, because a python.org install
ships no `python3` and that name reaches the Microsoft Store alias, which
either exits 49 with no output or starts a separate Store Python without the
packages `pip` installed (such as pypdf). `.claude/settings.json` already
allows both spellings, so no settings or guard change.
`tests/test_python_command_specs.py` globs `.claude/commands/*.md`,
`.claude/skills/**/*.md` and `.claude/agents/*.md` and fails if any spec
runs a bare `python`, or a spec that runs a tool carries no note, a second
note, or one after its first command.
### Security ### Security
- **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a
+1 -1
View File
@@ -140,7 +140,7 @@ Both documents MUST be compiled and visually inspected via the Read tool on the
- [ ] **Cover letter bullet font matches body font** - `\lettercontent{}` must not wrap `\begin{itemize}...\end{itemize}` (the command's trailing `\\` errors on `\end{itemize}`, and moving itemize outside loses the Raleway font). Standard pattern: close `\lettercontent{}`, then wrap the list in `{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont \begin{itemize}...\end{itemize}\par}` - [ ] **Cover letter bullet font matches body font** - `\lettercontent{}` must not wrap `\begin{itemize}...\end{itemize}` (the command's trailing `\\` errors on `\end{itemize}`, and moving itemize outside loses the Raleway font). Standard pattern: close `\lettercontent{}`, then wrap the list in `{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont \begin{itemize}...\end{itemize}\par}`
### ATS & keyword verification (CV) ### ATS & keyword verification (CV)
ATS parsers read the PDF's embedded text layer, not the rendered page. Extract it with `python tools/verify_pdf.py cv/main_<company>_<role>.pdf --dump-text cv/main_<company>_<role>.txt` (pypdf, then `pdftotext -layout -enc UTF-8`) and verify what a parser sees. If both extractors are missing, skip the parseability items with a warning and check keyword coverage from the visual PDF read instead. ATS parsers read the PDF's embedded text layer, not the rendered page. Extract it with `python3 tools/verify_pdf.py cv/main_<company>_<role>.pdf --dump-text cv/main_<company>_<role>.txt` (pypdf, then `pdftotext -layout -enc UTF-8`) and verify what a parser sees. If both extractors are missing, skip the parseability items with a warning and check keyword coverage from the visual PDF read instead.
- [ ] CV text layer extracts cleanly - no `(cid:*)` markers, `�` replacement characters, or text visible in the PDF but absent from the extraction - [ ] CV text layer extracts cleanly - no `(cid:*)` markers, `�` replacement characters, or text visible in the PDF but absent from the extraction
- [ ] Email and phone appear as **literal text** in the extraction (icon-glyph noise like `MOBILE-ALT`/`Envelope` is harmless, but a contact detail carried only by an icon or hyperlink is invisible to ATS) - [ ] Email and phone appear as **literal text** in the extraction (icon-glyph noise like `MOBILE-ALT`/`Envelope` is harmless, but a contact detail carried only by an icon or hyperlink is invisible to ATS)
- [ ] Reading order of the extracted text matches the visual order (single-column stock template is safe; multi-column custom templates are where this breaks) - [ ] Reading order of the extracted text matches the visual order (single-column stock template is safe; multi-column custom templates are where this breaks)
+1 -1
View File
@@ -32,7 +32,7 @@ def section(path, heading):
def page_count_invocations(text): def page_count_invocations(text):
"""(document path, page count) for every runnable verify_pdf --pages line.""" """(document path, page count) for every runnable verify_pdf --pages line."""
return re.findall( return re.findall(
r"^python tools/verify_pdf\.py (\S+) --pages (\d+)\s*$", text, re.MULTILINE r"^python3 tools/verify_pdf\.py (\S+) --pages (\d+)\s*$", text, re.MULTILINE
) )
+1 -1
View File
@@ -78,7 +78,7 @@ class TrustBoundaryWiring(unittest.TestCase):
text = RESEARCH.read_text(encoding="utf-8") text = RESEARCH.read_text(encoding="utf-8")
boundary = re.search(r"## Trust boundary(.*?)(?=\n## )", text, re.DOTALL).group(1) boundary = re.search(r"## Trust boundary(.*?)(?=\n## )", text, re.DOTALL).group(1)
self.assertIn("posting_guard", boundary) self.assertIn("posting_guard", boundary)
self.assertIn("framework_version: 1.1.5", text) self.assertIn("framework_version: 1.1.6", text)
if __name__ == "__main__": if __name__ == "__main__":
+116
View File
@@ -0,0 +1,116 @@
"""Guard: the workflow specs settle on `python3`, with one Windows note each.
The specs used to split between spellings: `/apply` and the skill files it
reads (`04-job-evaluation`, `05-cv-templates`) ran bare `python ...` while
`/rank`, `/scrape` and `09-web-research` ran `python3 ...` (#669). The specs
now all use `python3`, and every spec that runs a tool carries a single
Windows note at its first use: a python.org install on Windows ships no
`python3`, so that name reaches the Microsoft Store alias, which either exits
49 with no output or starts a separate Store Python without the packages
`pip` installed. The note points at `python`, and `.claude/settings.json`
keeps granting both spellings, so no settings or guard change is needed.
Nothing enforced either half. A later edit could reintroduce a bare `python
tools/...` line, or a second Windows note, without any test noticing;
`lint_skills.py` reads frontmatter, not command bodies, and the changelog
guard does not read the specs either. This does - over every command, skill
and agent spec rather than a fixed list, so a bare `python` line added to a
spec this file never named still fails.
Only line-leading interpreter tokens are checked - lines that actually run
Python from a code block. Prose that mentions a spelling mid-sentence (the
setup hint, the permission list) is left alone, as are the
`Bash(python ...)`/`Bash(python3 ...)` permission entries, which must keep
naming every spelling a user's machine might actually expose.
"""
import re
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SPEC_GLOBS = (
".claude/commands/*.md",
".claude/skills/**/*.md",
".claude/agents/*.md",
)
SPECS = sorted(
{path.relative_to(REPO).as_posix() for pattern in SPEC_GLOBS for path in REPO.glob(pattern)}
)
# Specs known to run a tool today; the glob must keep finding them.
KNOWN_TOOL_SPECS = {
".claude/commands/rank.md",
".claude/commands/apply.md",
".claude/skills/job-scraper/SKILL.md",
".claude/skills/job-application-assistant/04-job-evaluation.md",
".claude/skills/job-application-assistant/05-cv-templates.md",
".claude/skills/job-application-assistant/09-web-research.md",
}
WINDOWS_NOTE = "> **Windows:** run these tools as `python`, not `python3`."
# A line that starts by running an interpreter: the token at line start,
# optionally behind list indentation. `python3` is the settled spelling and
# never flagged; `python` (bare) and any `"$PY"`/probe reintroduction are.
LINE_INTERPRETER = re.compile(r"""^\s*(python3|python|"\$PY"|PY=)""")
def command_lines(text):
"""Yield (lineno, line) for every line-leading interpreter invocation."""
for lineno, line in enumerate(text.splitlines(), 1):
m = LINE_INTERPRETER.match(line)
if m and m.group(1) != "python3":
yield lineno, line, m.group(1)
class SpecPythonSpellingTest(unittest.TestCase):
def test_glob_covers_every_known_tool_spec(self):
missing = KNOWN_TOOL_SPECS - set(SPECS)
self.assertEqual(missing, set(), f"spec glob no longer finds {sorted(missing)}")
def test_every_command_invokes_python3(self):
problems = []
for spec in SPECS:
text = (REPO / spec).read_text(encoding="utf-8")
for lineno, line, token in command_lines(text):
problems.append(f"{spec}:{lineno}: {line.strip()}")
self.assertEqual(
problems,
[],
"workflow specs must invoke Python as `python3 ...` on every line - "
"the settled spelling (#669); a bare `python` breaks where distros "
"ship no python alias, and a `\"$PY\"` probe cannot work across "
"Claude Code's per-call shells:\n" + "\n".join(problems),
)
def test_each_tool_spec_carries_exactly_one_windows_note_before_first_use(self):
for spec in SPECS:
text = (REPO / spec).read_text(encoding="utf-8")
lines = text.splitlines()
first_use = next(
(i for i, l in enumerate(lines) if LINE_INTERPRETER.match(l)), None
)
notes = text.count(WINDOWS_NOTE)
if first_use is None:
self.assertEqual(
notes, 0, f"{spec} runs no tool but carries a Windows note"
)
continue
self.assertEqual(
notes,
1,
f"{spec} must carry exactly one Windows note (found {notes}) - "
"the note explains why Windows runs the tools as `python` (#669) "
"and must not be repeated after every command",
)
note_at = next(i for i, l in enumerate(lines) if l.startswith(WINDOWS_NOTE))
self.assertLess(
note_at,
first_use,
f"{spec} puts its Windows note after the first python3 command",
)
if __name__ == "__main__":
unittest.main()