diff --git a/.claude/commands/interview.md b/.claude/commands/interview.md index 560c387..0446c5b 100644 --- a/.claude/commands/interview.md +++ b/.claude/commands/interview.md @@ -25,7 +25,7 @@ v1 preps for a **specific application**. Generic no-target practice is out of sc - `job_posting.md` - the exact posting the user applied to - `cv_draft.tex` and `cover_letter.tex` - what was actually submitted. **These are what the interviewer read**; every talking point must be consistent with their claims. - `outcome.md` - the stage reached so far and any recorded feedback from earlier stages. Feedback from stage N is the highest-value input for stage N+1 prep. -2. **Fallbacks** (the application may predate `/outcome`): posting via WebFetch on the tracker row's `source` URL, or ask the user to paste it; CV via `cv/main_*.tex` and cover letter via `cover_letters/cover__*.tex`. State plainly which context is missing rather than guessing - and suggest `/outcome ` to build the archive for next time. +2. **Fallbacks** (the application may predate `/outcome`): posting via WebFetch on the tracker row's `source` URL, or ask the user to paste it; CV via `cv/main__.*` and cover letter via `cover_letters/cover__.*`, deriving `_` by the **Subfolder naming** rule in `documents/README.md`. **Never widen those globs to the company alone**: with two roles at one company it would prep you from the sibling role's documents. State plainly which context is missing rather than guessing - and suggest `/outcome ` to build the archive for next time. 3. **Ask the user what this interview is** (skip anything `outcome.md` already records): stage (phone screen / technical / case / final round), date, format (phone, video, onsite), and who is interviewing (names and titles, if known). 4. **Read the frameworks once** - do not re-read them in later steps: - `.claude/skills/job-application-assistant/07-interview-prep.md` diff --git a/.claude/commands/outcome.md b/.claude/commands/outcome.md index 64da40f..d27f417 100644 --- a/.claude/commands/outcome.md +++ b/.claude/commands/outcome.md @@ -161,7 +161,7 @@ Wait for the user's explicit response before writing anything. Create or update `documents/applications/_/`. All content here is personal data - the folder is already gitignored (`documents/applications/**`), so nothing needs redacting. -1. **`cv_draft.tex` and `cover_letter.tex`** - copy (never move) the submitted files. Locate them via the tracker row's `cv_file`/`cover_letter_file` columns; if those are empty, look for `cv/main_*.tex` and `cover_letters/cover__*.tex`. If a file already exists in the archive, leave it - the archived version is what was actually submitted. If no draft files exist (application made outside `/apply`), skip with a note. +1. **`cv_draft.tex` and `cover_letter.tex`** - copy (never move) the submitted files. Locate them via the tracker row's `cv_file`/`cover_letter_file` columns; if those are empty, look for `cv/main__.*` and `cover_letters/cover__.*`, deriving `_` by the **Subfolder naming** rule in `documents/README.md`. **Never widen those globs to the company alone** - two roles at one company both match it, and the first hit wins silently. If a file already exists in the archive, leave it - the archived version is what was actually submitted. If nothing matches (application made outside `/apply`), skip with a note rather than widening the search: a sibling role's CV recorded as what you submitted is worse than no file at all. 2. **`job_posting.md`** - if it already exists, leave it. Otherwise try WebFetch on the tracker row's `source` URL and save the posting text, retrying a 403 with browser headers per `.claude/skills/job-application-assistant/09-web-research.md`. If the URL is dead (postings expire fast - this is exactly why the archive matters), ask the user to paste the posting, or write a stub noting the posting is unavailable. **Never reconstruct a posting from memory.** 3. **`outcome.md`** - write or update it in exactly the format documented in `documents/README.md`, so `/setup` Path A parses it without special cases: diff --git a/CHANGELOG.md b/CHANGELOG.md index d8f3f72..789afcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,18 @@ per-file diff commands. ### Fixed +- **`/outcome` and `/interview` no longer confuse two roles at the same company** (#443) + (`.claude/commands/outcome.md`, `.claude/commands/interview.md`, + `tests/test_apply_records_application.py`) - when a tracker row's `cv_file` / + `cover_letter_file` columns are empty, both commands fell back to a company-prefix glob + (`cv/main_*.tex`). Two roles at one company both match it, so `/outcome` copied + whichever the filesystem returned first into the archive as `cv_draft.tex` - the file whose + purpose is to record what was actually submitted - and its own "leave an existing archived + file" rule then made the wrong copy permanent. Both fallbacks now glob the full + `_` stem, derived by the **Subfolder naming** rule in `documents/README.md` + rather than restated, and skip with a note instead of widening the search. Dropping the + hardcoded `.tex` also makes a template registered by `/add-template` findable. + - **`jobnet-search detail` no longer reports an externally hosted ad as not found** (#432) - Jobnet's `/FindJob/JobAdDetails/` returns 404 for ads with `isExternal: true`, so `detail` on an ad `search` had just listed exited 1 with `NOT_FOUND`, and `/scrape` read the posting as diff --git a/tests/test_apply_records_application.py b/tests/test_apply_records_application.py index bff4592..d6d04ab 100644 --- a/tests/test_apply_records_application.py +++ b/tests/test_apply_records_application.py @@ -11,6 +11,7 @@ byte-identical to /outcome's, which is the entire reason for reusing it. How each reader treats `drafted` is pinned per reader below, because the right answer differs between them. """ +import fnmatch import re import subprocess import sys @@ -385,6 +386,98 @@ class DeadlineSurvivesEveryWrite(unittest.TestCase): self.assertIn(needle, haystack, why) +class FallbackGlobFindsOneRolesDocuments(unittest.TestCase): + """The `cv_file` fallback must select one role's documents, not one company's. + + `/apply` names drafts `cv/main__`, so two roles + at one company differ only in the role half. When the tracker row's + `cv_file`/`cover_letter_file` columns are empty - a row written before + #291, added by hand, or by /outcome's own outside-the-workflow path - + both readers fall back to a glob. A company-prefix glob matches both + roles and the first hit wins silently: /outcome copies it to + `cv_draft.tex`, and its own "leave an existing archived file" rule then + makes the wrong answer permanent (#443). + + The globs are extracted from the specs rather than restated here, so + these tests pin what the specs actually say. + """ + + COMPANY = "Acme" + ROLES = ("Data Scientist", "ML Engineer", "ML Engineer II") + + CASES = [ + (OUTCOME, "## Step 3: Archive the Application Materials", + "by the **Subfolder naming** rule in `documents/README.md`", + "the archive locator must derive the stem by the one documented rule, " + "not invent a second derivation that drifts from it"), + (OUTCOME, "## Step 3: Archive the Application Materials", + "Never widen those globs to the company alone", + "without the prohibition the next edit relaxes the glob when it finds " + "no match, which is exactly the wrong-file-recorded-as-submitted case"), + (INTERVIEW, "## Step 1: Load the Application Context", + "by the **Subfolder naming** rule in `documents/README.md`", + "interview's fallback must resolve the same stem /apply wrote"), + (INTERVIEW, "## Step 1: Load the Application Context", + "Never widen those globs to the company alone", + "prep built from the sibling role's CV is a live-conversation failure"), + ] + + def test_both_readers_glob_the_full_stem(self): + for path, heading, needle, why in self.CASES: + with self.subTest(file=path.name, rule=needle): + self.assertIn(needle, section(path, heading), why) + + @staticmethod + def globs(path, heading): + """The two fallback globs exactly as the spec writes them.""" + body = section(path, heading) + found = re.findall(r"`(cv/main_[^`]+|cover_letters/cover_[^`]+)`", body) + return [g for g in found if "*" in g] + + def resolve(self, glob, role): + """Substitute the spec's placeholders the way the reader would.""" + stem = ArchiveNameIsOnePathComponent.derive(self.COMPANY, role) + company = ArchiveNameIsOnePathComponent.derive(self.COMPANY, "").rstrip("_") + return glob.replace("_", stem).replace("", company) + + def drafted_files(self, ext=".tex"): + """Exactly what /apply Step 5 leaves in cv/ for two roles at one company.""" + return [ + "cv/main_%s%s" % (ArchiveNameIsOnePathComponent.derive(self.COMPANY, r), ext) + for r in self.ROLES + ] + + def test_the_cv_glob_selects_the_row_s_own_role(self): + on_disk = self.drafted_files() + for path, heading in ((OUTCOME, "## Step 3: Archive the Application Materials"), + (INTERVIEW, "## Step 1: Load the Application Context")): + cv_glob = next(g for g in self.globs(path, heading) if g.startswith("cv/")) + for role, expected in zip(self.ROLES, on_disk): + with self.subTest(file=path.name, role=role): + hits = fnmatch.filter(on_disk, self.resolve(cv_glob, role)) + self.assertEqual( + hits, [expected], + "%s's fallback glob %r matched %r for role %r. A glob that " + "matches both roles hands /outcome whichever the filesystem " + "returns first, and it archives that as what was submitted." + % (path.name, cv_glob, hits, role), + ) + + def test_the_glob_finds_a_non_tex_template(self): + """`/add-template` makes `.typ` a real output; a hardcoded `.tex` misses it.""" + on_disk = self.drafted_files(ext=".typ") + cv_glob = next( + g for g in self.globs(OUTCOME, "## Step 3: Archive the Application Materials") + if g.startswith("cv/") + ) + hits = fnmatch.filter(on_disk, self.resolve(cv_glob, self.ROLES[0])) + self.assertEqual( + hits, [on_disk[0]], + "the fallback hardcodes an extension, so a template registered by " + "/add-template is invisible to it and /outcome archives nothing", + ) + + class ArchiveNameIsOnePathComponent(unittest.TestCase): """`_` must derive a single path component.