mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
fix(setup): fill the CV and cover-letter template contact blocks; guard CHANGELOG structure (#433)
/setup Step 3 personalised cv/main_example.tex but never the LaTeX contact blocks embedded in 05-cv-templates.md and 06-cover-letter-templates.md, the two files /apply actually compiles from; 06 was not a Step 3 target at all. Step 3.5 now names the 05 contact tokens, a new Step 3.6 covers the 06 contact line and signature, the completion summary lists 06, and /reset restores both blocks instead of listing 06 as framework-only (the existing /reset coverage test forced that half). tests/test_changelog_structure.py checks [Unreleased] on every PR for duplicate headings, unknown headings, orphan entries and conflict markers - the #425 duplicate-heading shape that was fixed by hand at merge time. Claude-Session: https://claude.ai/code/session_013fqqLgQSnwgWkv98twQhHi Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
3bf41149e0
commit
e6f6f4e322
@@ -41,7 +41,8 @@ Read the current state of these files and report whether each has content or is
|
||||
- `.claude/skills/job-application-assistant/01-candidate-profile.md`
|
||||
- `.claude/skills/job-application-assistant/02-behavioral-profile.md`
|
||||
- `.claude/skills/job-application-assistant/04-job-evaluation.md` *(personalized match areas, career goals, and life-situation constraints only — the scoring framework is preserved)*
|
||||
- `.claude/skills/job-application-assistant/05-cv-templates.md` *(profile statements section only — framework structure is preserved)*
|
||||
- `.claude/skills/job-application-assistant/05-cv-templates.md` *(profile statements section and the contact block inside the LaTeX template only — framework structure is preserved)*
|
||||
- `.claude/skills/job-application-assistant/06-cover-letter-templates.md` *(contact line and signature inside the LaTeX template only — framework structure is preserved)*
|
||||
- `.claude/skills/job-application-assistant/07-interview-prep.md` *(STAR examples and STAR candidates sections only — framework structure is preserved)*
|
||||
- `.claude/skills/job-scraper/search-queries.md` *(role titles, domain keywords, and location terms only — query structure is preserved)*
|
||||
|
||||
@@ -63,8 +64,11 @@ Present as:
|
||||
constraints will be restored to placeholders. The scoring framework (dimensions,
|
||||
score bands, weights, Language Gate, Company Research Checklist) is preserved.
|
||||
|
||||
- 05-cv-templates.md — [has profile statements / already blank]
|
||||
Profile statement templates will be cleared. LaTeX structure and tailoring guidelines are preserved.
|
||||
- 05-cv-templates.md — [has profile statements or contact details / already blank]
|
||||
Profile statement templates will be cleared and the contact block in the LaTeX template restored to placeholders. LaTeX structure and tailoring guidelines are preserved.
|
||||
|
||||
- 06-cover-letter-templates.md — [has contact details / already blank]
|
||||
The contact line and signature in the LaTeX template will be restored to placeholders. Letter structure, opening patterns, and closing formulations are preserved.
|
||||
|
||||
- 07-interview-prep.md — [has STAR examples / already blank]
|
||||
STAR examples and any STAR candidate stubs will be cleared. Framework, tough questions, and roleplay guidelines are preserved.
|
||||
@@ -75,7 +79,6 @@ Present as:
|
||||
|
||||
The following files are NOT touched (they contain framework rules, not candidate data):
|
||||
- 03-writing-style.md
|
||||
- 06-cover-letter-templates.md
|
||||
|
||||
Outside the profile scope, still holding your personal data: CLAUDE.md and
|
||||
cv/main_example.tex. This scope covers skill files only.
|
||||
@@ -207,7 +210,9 @@ Leave the rest of `04-job-evaluation.md` intact: the five scoring dimensions and
|
||||
<!-- Run /setup to populate role-specific profile statements -->
|
||||
```
|
||||
|
||||
Leave all other content in `05-cv-templates.md` intact.
|
||||
Then restore the contact block inside the file's LaTeX template to its placeholder tokens: `\name{[FIRST_NAME]}{[LAST_NAME]}`, `\address{[YOUR_ADDRESS]}{}{}`, `\phone[mobile]{[YOUR_PHONE]}`, `\email{[YOUR_EMAIL]}`, the `\extrainfo{...}` line's `[YOUR_LINKEDIN_URL]` and `[YOUR_GITHUB_URL]`, and `[YOUR_NAME]` in the `pdftitle`. Leave all other content in `05-cv-templates.md` intact.
|
||||
|
||||
**For `06-cover-letter-templates.md`**, restore the contact line and the signature inside the file's LaTeX template to their placeholder tokens: the `\namesection{}` line becomes `\namesection{}{\Huge{[YOUR_NAME]}}{ \href{mailto:[YOUR_EMAIL]}{[YOUR_EMAIL]} | [YOUR_PHONE] | \urlstyle{same}\href{[YOUR_LINKEDIN_URL]}{LinkedIn}` and `\signature{...}` becomes `\signature{[YOUR_NAME]}`. Leave all other content in `06-cover-letter-templates.md` intact - the letter structure, opening patterns, and closing formulations are framework, not candidate data. If `/setup` Step 3.6 ever personalizes anything beyond these two lines, add it here too.
|
||||
|
||||
**For `07-interview-prep.md`**, locate and remove:
|
||||
- The entire `## Ready-Made STAR Examples` section and all numbered STAR examples under it
|
||||
@@ -223,7 +228,7 @@ Replace with:
|
||||
|
||||
Leave all other content in `07-interview-prep.md` intact (STAR format explanation, tough questions, questions to ask interviewers, phone/video tips, follow-up etiquette, roleplay guidelines).
|
||||
|
||||
**For `.claude/skills/job-scraper/search-queries.md`**, restore the values `/setup` Step 3.8 personalized back to their placeholder tokens:
|
||||
**For `.claude/skills/job-scraper/search-queries.md`**, restore the values `/setup` Step 3.9 personalized back to their placeholder tokens:
|
||||
|
||||
- **Search Sites**: the board names back to `[YOUR_JOB_BOARD]`, `[YOUR_INDUSTRY_JOB_BOARD]`, `[YOUR_ADDITIONAL_JOB_BOARD]`, and the LinkedIn filter back to `[YOUR_COUNTRY]` / `[YOUR_CITY]`.
|
||||
- **Query Categories**: the four priority headings back to `[YOUR_PRIMARY_ROLE_TYPE]`, `[YOUR_DOMAIN_EXPERTISE]`, `[YOUR_ADJACENT_ROLE_TYPE]`, and `Broader Technical / Consulting`; inside the query blocks, the titles, skills, and domain terms back to `[YOUR_PRIMARY_JOB_TITLE_1]`, `[YOUR_PRIMARY_JOB_TITLE_2]`, `[YOUR_ADJACENT_TITLE_1]`, `[YOUR_ADJACENT_TITLE_2]`, `[YOUR_KEY_SKILL]`, `[YOUR_DOMAIN_KEYWORD_1]`, `[YOUR_DOMAIN_KEYWORD_2]`, `[YOUR_DOMAIN]`, and the location terms back to `[YOUR_CITY]`, `[YOUR_COUNTRY]`, `[YOUR_REGION]`.
|
||||
|
||||
@@ -367,15 +367,18 @@ Replace skill match areas with the user's actual skills:
|
||||
Update career goals and motivation filters with their actual preferences.
|
||||
|
||||
### 5. Update `05-cv-templates.md` *(Path B and C; skip if Path A populated it)*
|
||||
Add role-specific profile statement templates based on their background.
|
||||
Add role-specific profile statement templates based on their background, and personalise the contact block inside the file's LaTeX template: replace `[FIRST_NAME]`, `[LAST_NAME]`, `[YOUR_ADDRESS]`, `[YOUR_PHONE]`, `[YOUR_EMAIL]`, `[YOUR_LINKEDIN_URL]` and `[YOUR_GITHUB_URL]` (and `[YOUR_NAME]` in the PDF title) with their actual details. Check this block whichever path ran - Path A extracts profile statements from documents, not the contact block. `/apply` builds every tailored CV from this template, so a placeholder left here reaches a compiled document.
|
||||
|
||||
### 6. Update `07-interview-prep.md` *(Path B and C; skip if Path A populated it)*
|
||||
### 6. Update `06-cover-letter-templates.md` *(all paths - Path A does not fill this block)*
|
||||
Personalise the contact line and the signature inside the file's LaTeX template: replace `[YOUR_NAME]`, `[YOUR_EMAIL]`, `[YOUR_PHONE]` and `[YOUR_LINKEDIN_URL]` in the `\namesection{}` line, and `[YOUR_NAME]` in `\signature{}`. Path A merges only structural patterns (openings, bullets, closings) into this file, never the contact block. `/apply` compiles every cover letter from this template.
|
||||
|
||||
### 7. Update `07-interview-prep.md` *(Path B and C; skip if Path A populated it)*
|
||||
Create STAR examples from their actual experience (at least 3-4 examples). Path A leaves STAR stubs under "## STAR Candidates (Complete Manually)" rather than full examples; if any stubs are present, mention them in Step 4 so the user knows to flesh them out.
|
||||
|
||||
### 7. Update `cv/main_example.tex`
|
||||
### 8. Update `cv/main_example.tex`
|
||||
Replace placeholder personal data with their actual name, contact info, and add their education and most recent experience entries.
|
||||
|
||||
### 8. Generate `.claude/skills/job-scraper/search-queries.md`
|
||||
### 9. Generate `.claude/skills/job-scraper/search-queries.md`
|
||||
Replace all placeholder tokens in the search queries file with the user's actual information from Section 9 (or the equivalent follow-up questions in Path A's Step A7):
|
||||
- Replace `[YOUR_PRIMARY_ROLE_TYPE]`, `[YOUR_PRIMARY_JOB_TITLE]`, etc. with actual role titles
|
||||
- Replace `[YOUR_KEY_SKILL]`, `[YOUR_DOMAIN_KEYWORD_1]`, etc. with actual skills and domain terms
|
||||
@@ -399,7 +402,8 @@ Present a summary:
|
||||
> - `.claude/skills/job-application-assistant/01-candidate-profile.md` - Structured profile
|
||||
> - `.claude/skills/job-application-assistant/02-behavioral-profile.md` - Behavioral assessment
|
||||
> - `.claude/skills/job-application-assistant/04-job-evaluation.md` - Personalized evaluation framework
|
||||
> - `.claude/skills/job-application-assistant/05-cv-templates.md` - CV templates with your profile statements
|
||||
> - `.claude/skills/job-application-assistant/05-cv-templates.md` - CV templates with your profile statements and contact block
|
||||
> - `.claude/skills/job-application-assistant/06-cover-letter-templates.md` - Cover letter templates with your contact line and signature
|
||||
> - `.claude/skills/job-application-assistant/07-interview-prep.md` - STAR examples from your experience
|
||||
> - `cv/main_example.tex` - Your LaTeX CV template
|
||||
> - `.claude/skills/job-scraper/search-queries.md` - Job search queries for `/scrape`
|
||||
|
||||
@@ -15,6 +15,13 @@ per-file diff commands.
|
||||
|
||||
### Added
|
||||
|
||||
- **CHANGELOG structure guard** (`tests/test_changelog_structure.py`) - every PR edits this one
|
||||
shared file by hand near the same line, and nothing checked the result: a second `### Fixed`
|
||||
heading landed directly under `[Unreleased]`, above `### Added`, on #425 and was fixed by hand
|
||||
at merge time. The `[Unreleased]` section is now checked on every PR for duplicate headings,
|
||||
headings outside the Keep a Changelog set, entries above any heading, and leftover conflict
|
||||
markers. Released sections are history and are not inspected.
|
||||
|
||||
- **`/rank` now consumes the `posted_date` #391 persists** (#390, the deferred second
|
||||
half) - Step 3 gains a staleness flag: a posting whose stored `posted_date` is more
|
||||
than 30 days old at rank time carries a visible ⚠ marker with its age spelled out
|
||||
@@ -41,6 +48,18 @@ per-file diff commands.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`/setup` now fills the contact blocks inside `05-cv-templates.md` and
|
||||
`06-cover-letter-templates.md`, and `/reset` restores them** - Step 3 personalised
|
||||
`cv/main_example.tex` but never the LaTeX contact blocks embedded in the two template files
|
||||
`/apply` actually compiles from, so a full Path B or C run left `[YOUR_NAME]`, `[YOUR_EMAIL]`
|
||||
and `[YOUR_PHONE]` in both, and whether they reached a document depended on the drafter
|
||||
noticing (a real user ran `/setup` and then hand-edited both files, #420).
|
||||
`06-cover-letter-templates.md` was not a Step 3 target at all. Step 3.5 now names the `05`
|
||||
contact tokens, a new Step 3.6 covers the `06` contact line and signature (Path A never fills
|
||||
it, so it runs for every path), the completion summary lists `06`, and `/reset` clears both
|
||||
blocks instead of listing `06` as framework-only. Pinned by `tests/test_setup_command.py`; the
|
||||
existing `/reset` coverage test is what forced the `reset.md` half.
|
||||
|
||||
- **`/rank` no longer reads or rewrites the whole of `seen_jobs.json` on every run** (#395) -
|
||||
Step 1 used to read the entire state file into the conversation to select candidates by
|
||||
eye, and Step 4 emitted it back to record scores: a cost paid on every run regardless of
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Structural guard for CHANGELOG.md's [Unreleased] section.
|
||||
|
||||
Contributors edit one shared file by hand, and every PR inserts its entry near
|
||||
the same line. Two failure shapes have reached master or a merge queue:
|
||||
|
||||
- a second `### Fixed` heading added directly under `## [Unreleased]` because
|
||||
the author did not see the existing one further down (#425, fixed by hand at
|
||||
merge time), and
|
||||
- entries placed above any `###` heading, or under a heading Keep a Changelog
|
||||
does not define.
|
||||
|
||||
`lint_skills.py` does not read the changelog, so nothing caught either. This
|
||||
test does, on every PR. It only inspects [Unreleased]; released sections are
|
||||
history and stay as they are.
|
||||
"""
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CHANGELOG = REPO / "CHANGELOG.md"
|
||||
|
||||
KNOWN_HEADINGS = {"Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"}
|
||||
CONFLICT_MARKERS = ("<<<<<<< ", "=======", ">>>>>>> ")
|
||||
|
||||
|
||||
def unreleased_block(text: str) -> str:
|
||||
"""The lines between `## [Unreleased]` and the next `## [` heading."""
|
||||
start = text.index("## [Unreleased]")
|
||||
end = text.find("\n## [", start + 1)
|
||||
return text[start:] if end == -1 else text[start:end]
|
||||
|
||||
|
||||
def unreleased_problems(text: str) -> list[str]:
|
||||
"""Return a human-readable problem per structural defect in [Unreleased]."""
|
||||
problems: list[str] = []
|
||||
seen: list[str] = []
|
||||
current: str | None = None
|
||||
for lineno, line in enumerate(unreleased_block(text).splitlines(), 1):
|
||||
if any(line.startswith(marker) for marker in CONFLICT_MARKERS):
|
||||
problems.append(f"conflict marker on [Unreleased] line {lineno}: {line.strip()}")
|
||||
continue
|
||||
if line.startswith("### "):
|
||||
name = line[4:].strip()
|
||||
if name not in KNOWN_HEADINGS:
|
||||
problems.append(
|
||||
f"unknown heading '### {name}' in [Unreleased]; use one of {sorted(KNOWN_HEADINGS)}"
|
||||
)
|
||||
if name in seen:
|
||||
problems.append(
|
||||
f"'### {name}' appears twice in [Unreleased] - fold the entry into the existing section"
|
||||
)
|
||||
seen.append(name)
|
||||
current = name
|
||||
elif line.startswith("- ") and current is None:
|
||||
problems.append(f"entry above any '###' heading in [Unreleased]: {line.strip()[:70]}")
|
||||
return problems
|
||||
|
||||
|
||||
CLEAN = """# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **A new thing** - described.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A fixed thing** - described.
|
||||
|
||||
## [1.0.0] - 2026-01-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- old entry
|
||||
"""
|
||||
|
||||
|
||||
class UnreleasedProblemsTests(unittest.TestCase):
|
||||
def test_clean_section_reports_nothing(self):
|
||||
self.assertEqual(unreleased_problems(CLEAN), [])
|
||||
|
||||
def test_duplicate_heading_is_reported(self):
|
||||
# The exact #425 shape: a second "### Fixed" inserted directly under
|
||||
# [Unreleased], above "### Added", while "### Fixed" already exists below.
|
||||
text = CLEAN.replace(
|
||||
"## [Unreleased]\n\n### Added",
|
||||
"## [Unreleased]\n\n### Fixed\n\n- **Entry in the wrong place** - described.\n\n### Added",
|
||||
)
|
||||
problems = unreleased_problems(text)
|
||||
self.assertTrue(any("Fixed" in p and "twice" in p for p in problems), problems)
|
||||
|
||||
def test_unknown_heading_is_reported(self):
|
||||
text = CLEAN.replace("### Fixed", "### Fixes")
|
||||
problems = unreleased_problems(text)
|
||||
self.assertTrue(any("Fixes" in p for p in problems), problems)
|
||||
|
||||
def test_entry_above_any_heading_is_reported(self):
|
||||
text = CLEAN.replace(
|
||||
"## [Unreleased]\n\n### Added",
|
||||
"## [Unreleased]\n\n- **Orphan entry** - no heading above it.\n\n### Added",
|
||||
)
|
||||
problems = unreleased_problems(text)
|
||||
self.assertTrue(any("Orphan entry" in p for p in problems), problems)
|
||||
|
||||
def test_conflict_markers_are_reported(self):
|
||||
text = CLEAN.replace("### Fixed", "<<<<<<< HEAD\n### Fixed")
|
||||
problems = unreleased_problems(text)
|
||||
self.assertTrue(any("conflict marker" in p for p in problems), problems)
|
||||
|
||||
def test_released_sections_are_not_inspected(self):
|
||||
# A duplicate heading in an old release is history, not a defect here.
|
||||
text = CLEAN + "\n### Fixed\n\n- another old entry\n"
|
||||
self.assertEqual(unreleased_problems(text), [])
|
||||
|
||||
|
||||
class RealChangelogTests(unittest.TestCase):
|
||||
def test_unreleased_section_is_well_formed(self):
|
||||
text = CHANGELOG.read_text(encoding="utf-8")
|
||||
self.assertEqual(unreleased_problems(text), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -32,7 +32,7 @@ PROFILE_SENTINEL = "[YOUR_EMAIL]"
|
||||
|
||||
|
||||
def personalize_cv(text: str) -> str:
|
||||
"""Apply /setup Step 3.7's documented edit: replace placeholder personal
|
||||
"""Apply /setup Step 3.8's documented edit: replace placeholder personal
|
||||
data with a real name and contact info. Header comments and hyperref
|
||||
metadata are not personal data, so they are deliberately left alone -
|
||||
that is exactly why a comment-located sentinel guards nothing."""
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Guards for the /setup command spec.
|
||||
|
||||
The command is a markdown spec (the spec IS the implementation). These tests pin
|
||||
one invariant that broke silently: Step 3 must personalise every contact block
|
||||
that `/apply` later compiles into a document. `cv/main_example.tex` was covered;
|
||||
the LaTeX blocks embedded in `05-cv-templates.md` and `06-cover-letter-templates.md`
|
||||
were not, so a full Path B/C run left `[YOUR_NAME]`, `[YOUR_EMAIL]` and
|
||||
`[YOUR_PHONE]` in both, and whether they reached a compiled cover letter depended
|
||||
on the drafter noticing. A real user (#420) ran `/setup` and then hand-edited both
|
||||
files to close the gap.
|
||||
"""
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
COMMAND = REPO / ".claude" / "commands" / "setup.md"
|
||||
SKILL_DIR = REPO / ".claude" / "skills" / "job-application-assistant"
|
||||
CV_TEMPLATES = SKILL_DIR / "05-cv-templates.md"
|
||||
COVER_TEMPLATES = SKILL_DIR / "06-cover-letter-templates.md"
|
||||
|
||||
|
||||
def _sections(text: str) -> dict[str, str]:
|
||||
"""Split a command spec into {heading: body} by '## ' headers."""
|
||||
parts = text.split("\n## ")
|
||||
result = {}
|
||||
for part in parts[1:]:
|
||||
heading, _, body = part.partition("\n")
|
||||
result[heading.strip()] = body
|
||||
return result
|
||||
|
||||
|
||||
def _substeps(step_body: str) -> dict[str, str]:
|
||||
"""Split a step body into {'### N. ...' heading: body}."""
|
||||
parts = step_body.split("\n### ")
|
||||
result = {}
|
||||
for part in parts[1:]:
|
||||
heading, _, body = part.partition("\n")
|
||||
result[heading.strip()] = body
|
||||
return result
|
||||
|
||||
|
||||
class SetupStep3ContactBlocks(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.step3 = _sections(COMMAND.read_text(encoding="utf-8"))["Step 3: Generate Profile Files"]
|
||||
self.substeps = _substeps(self.step3)
|
||||
|
||||
def _substep_for(self, filename: str) -> str:
|
||||
matches = [body for heading, body in self.substeps.items() if filename in heading]
|
||||
self.assertEqual(len(matches), 1, f"expected exactly one Step 3 substep for {filename}, got {len(matches)}")
|
||||
return matches[0]
|
||||
|
||||
def test_cv_templates_substep_fills_the_contact_block(self):
|
||||
body = self._substep_for("05-cv-templates.md")
|
||||
self.assertIn("contact", body.lower())
|
||||
for token in ("[FIRST_NAME]", "[YOUR_EMAIL]", "[YOUR_PHONE]"):
|
||||
self.assertIn(token, body, f"the 05 substep must name {token} as something to replace")
|
||||
|
||||
def test_cover_letter_templates_get_their_own_substep(self):
|
||||
body = self._substep_for("06-cover-letter-templates.md")
|
||||
self.assertIn("signature", body.lower())
|
||||
for token in ("[YOUR_NAME]", "[YOUR_EMAIL]", "[YOUR_PHONE]", "[YOUR_LINKEDIN_URL]"):
|
||||
self.assertIn(token, body, f"the 06 substep must name {token} as something to replace")
|
||||
|
||||
def test_completion_summary_lists_the_cover_letter_templates(self):
|
||||
step4 = _sections(COMMAND.read_text(encoding="utf-8"))["Step 4: Confirm & Next Steps"]
|
||||
summary = step4.split("**Privacy note:**")[0]
|
||||
self.assertIn("06-cover-letter-templates.md", summary)
|
||||
|
||||
|
||||
class TemplatesStillCarryThePlaceholders(unittest.TestCase):
|
||||
"""The instructions above target real tokens; if a template renames them,
|
||||
the instruction and this test must move together."""
|
||||
|
||||
def test_cv_templates_contact_block_tokens(self):
|
||||
text = CV_TEMPLATES.read_text(encoding="utf-8")
|
||||
for token in ("[FIRST_NAME]", "[LAST_NAME]", "[YOUR_EMAIL]", "[YOUR_PHONE]"):
|
||||
self.assertIn(token, text)
|
||||
|
||||
def test_cover_letter_templates_contact_and_signature_tokens(self):
|
||||
text = COVER_TEMPLATES.read_text(encoding="utf-8")
|
||||
for token in ("[YOUR_NAME]", "[YOUR_EMAIL]", "[YOUR_PHONE]", "[YOUR_LINKEDIN_URL]"):
|
||||
self.assertIn(token, text)
|
||||
self.assertIn("\\signature{[YOUR_NAME]}", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user