fix(outcome,interview): glob the full <company>_<role> stem in the CV fallback (#444)

When a tracker row's cv_file/cover_letter_file columns are empty, /outcome
and /interview fell back to a company-prefix glob (cv/main_<company>*.tex).
/apply names drafts main_<company>_<role><CV_EXT>, so two roles at one
company both match that glob. /outcome copied whichever the filesystem
returned first into the archive as cv_draft.tex - the file whose stated
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 cv/main_<company>_<role>.* and
cover_letters/cover_<company>_<role>.*, deriving the stem by the Subfolder
naming rule in documents/README.md rather than restating it, and skip with
a note instead of widening the search. Dropping the hardcoded .tex also
makes a template registered by /add-template findable.

The dot before the extension wildcard matters: a bare trailing * also
absorbs a longer role, so ML Engineer and ML Engineer II at one company
would collide the same way the company-prefix glob did.
This commit is contained in:
Jakob Stender Guldberg
2026-09-10 20:32:37 +02:00
committed by GitHub
parent cbd8a991ab
commit c776e3f2b6
4 changed files with 107 additions and 2 deletions
+1 -1
View File
@@ -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 - `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. - `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. - `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_<company>*.tex` and cover letter via `cover_letters/cover_<company>_*.tex`. State plainly which context is missing rather than guessing - and suggest `/outcome <company>` 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_<company>_<role>.*` and cover letter via `cover_letters/cover_<company>_<role>.*`, deriving `<company>_<role>` 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 <company>` 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). 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: 4. **Read the frameworks once** - do not re-read them in later steps:
- `.claude/skills/job-application-assistant/07-interview-prep.md` - `.claude/skills/job-application-assistant/07-interview-prep.md`
+1 -1
View File
@@ -161,7 +161,7 @@ Wait for the user's explicit response before writing anything.
Create or update `documents/applications/<company>_<role>/`. All content here is personal data - the folder is already gitignored (`documents/applications/**`), so nothing needs redacting. Create or update `documents/applications/<company>_<role>/`. 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_<company>*.tex` and `cover_letters/cover_<company>_*.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_<company>_<role>.*` and `cover_letters/cover_<company>_<role>.*`, deriving `<company>_<role>` 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.** 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: 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:
+12
View File
@@ -47,6 +47,18 @@ per-file diff commands.
### Fixed ### 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_<company>*.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
`<company>_<role>` 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-search detail` no longer reports an externally hosted ad as not found** (#432) -
Jobnet's `/FindJob/JobAdDetails/<id>` returns 404 for ads with `isExternal: true`, so `detail` Jobnet's `/FindJob/JobAdDetails/<id>` 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 on an ad `search` had just listed exited 1 with `NOT_FOUND`, and `/scrape` read the posting as
+93
View File
@@ -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 How each reader treats `drafted` is pinned per reader below, because the
right answer differs between them. right answer differs between them.
""" """
import fnmatch
import re import re
import subprocess import subprocess
import sys import sys
@@ -385,6 +386,98 @@ class DeadlineSurvivesEveryWrite(unittest.TestCase):
self.assertIn(needle, haystack, why) 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_<company>_<role><CV_EXT>`, 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("<company>_<role>", stem).replace("<company>", 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): class ArchiveNameIsOnePathComponent(unittest.TestCase):
"""`<company>_<role>` must derive a single path component. """`<company>_<role>` must derive a single path component.