mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
fix(archive): derive <company>_<role> as a single path component (#329)
Extends the canonical Subfolder-naming rule by citation to all six archive derivation sites (apply, gmail-sync, interview, notion-sync, outcome, assistant SKILL.md), adds a fail-closed guard for an empty derived name, and pins every site with mutation-verified tests. framework_version 1.3.3 -> 1.3.4. jakob1379 independently specified the same fix in his fork's issue #22 before this PR's rework. Co-authored-by: ayobamiseun <66267222+ayobamiseun@users.noreply.github.com>
This commit is contained in:
co-authored by
ayobamiseun
parent
5c423b4064
commit
2ff1085254
@@ -81,6 +81,8 @@ Also read the most recent existing CV and cover letter files for concrete struct
|
||||
- **Engage nice-to-haves by name** where the profile supports honest adjacency (e.g. "conceptually aligned with <named tool>"), and use the posting's own term over a synonym wherever it is truthfully applicable - including in CV section headings (a posting hiring for "MLOps" should find a heading containing "MLOps", not only a paraphrase).
|
||||
- **Address stated logistics and prerequisites** in the cover letter where the posting raises them: security clearance willingness, start date or availability, commute or location fit, and the posting's reference/job ID where one exists. When the employer operates across several countries, a truthful language-capabilities sentence mapped to their footprint is high-value targeting.
|
||||
|
||||
*In both filenames below, `<company>_<role>` is derived by the **Subfolder naming** rule in `documents/README.md` — the same rule `/outcome` Step 1.4 uses for the archive folder, so a `/` or other path character in a company or role name can never split the filename across directories.*
|
||||
|
||||
### CV (`cv/main_<company>_<role><CV_EXT>`)
|
||||
- In the **CV language from the profile** (the `CV language:` line in CLAUDE.md's Identity section). When the profile does not set one, default to **English**. Never switch language per posting - the CV language is a profile-level choice, so all CVs stay consistent and reusable
|
||||
- Follow the moderncv/banking format from `05-cv-templates.md`
|
||||
|
||||
@@ -28,7 +28,7 @@ Confirm the Gmail MCP tools (`mcp__claude_ai_Gmail__*`) are available. If not, t
|
||||
|
||||
1. Read `job_search_tracker.csv`. If it does not exist, tell the user there is nothing to sync against yet (suggest `/outcome` or `/apply` first) and stop. Do not create it here - `/gmail-sync` never originates new applications, only updates existing ones.
|
||||
2. Read `gmail_sync/state.json` (create if missing: `{"last_sync": null, "processed_message_ids": []}`).
|
||||
3. Build the set of **open applications**: tracker rows whose `status` is not **Final** (per the **Tracker status vocabulary** in `/outcome`). For each, derive its archive folder `documents/applications/<company>_<role>/` (lowercase, underscores - same convention as `/outcome`) and check whether `outcome.md` exists there.
|
||||
3. Build the set of **open applications**: tracker rows whose `status` is not **Final** (per the **Tracker status vocabulary** in `/outcome`). For each, derive its archive folder `documents/applications/<company>_<role>/` by the **Subfolder naming** rule in `documents/README.md` and check whether `outcome.md` exists there. Reuse this exact derived path for any write in Step 7a.
|
||||
|
||||
**`drafted` rows stay in this set, and are the reason it is worth searching.** `/apply` writes them but never submits; the user submits by hand and may not think to run `/outcome`. A reply arriving against a row still marked `drafted` is exactly that case, and the row holds the company name the search needs.
|
||||
4. If `$ARGUMENTS` named a company, filter this set to the matching row(s) (case-insensitive). No match → tell the user and stop, do not guess.
|
||||
|
||||
@@ -21,7 +21,7 @@ v1 preps for a **specific application**. Generic no-target practice is out of sc
|
||||
|
||||
## Step 1: Load the Application Context
|
||||
|
||||
1. **The archive** (started by `/apply`, maintained by `/outcome`): `documents/applications/<company>_<role>/`
|
||||
1. **The archive** (started by `/apply`, maintained by `/outcome`): derive `<company>_<role>` by the **Subfolder naming** rule in `documents/README.md`, then use `documents/applications/<company>_<role>/`.
|
||||
- `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.
|
||||
@@ -76,7 +76,7 @@ Pick 4-6 from `07`'s categories, customized to the research and the stage: role
|
||||
### 6. Logistics
|
||||
The phone/video tips from `07` when the format calls for them, plus date and interviewer names as a header.
|
||||
|
||||
Save the pack to `documents/applications/<company>_<role>/interview_prep_<stage>.md` (create the folder if this application predates `/outcome`). The folder is gitignored, so the pack stays personal; one file per stage, so earlier packs remain as history. Present the pack in chat as well - the file is the artifact, the conversation is the delivery.
|
||||
Save the pack in the archive folder derived in Step 1 as `interview_prep_<stage>.md` (create the folder if this application predates `/outcome`). The folder is gitignored, so the pack stays personal; one file per stage, so earlier packs remain as history. Present the pack in chat as well - the file is the artifact, the conversation is the delivery.
|
||||
|
||||
---
|
||||
|
||||
@@ -104,6 +104,6 @@ If Step 3 drafted new STAR answers the user approved for keeps, remind them thos
|
||||
2. **Honesty on gaps.** Weak matches get bridge answers (acknowledge → adjacent experience → learning path), never invented experience. Same rule as everywhere else in this repo.
|
||||
3. **Verified research only.** Company specifics go in the pack only after independent confirmation. Interviewer notes stick to public professional information.
|
||||
4. **Stage-appropriate prep.** A phone screen pack and a final-round pack are different documents; recorded feedback from earlier stages takes priority over generic question lists.
|
||||
5. **Write only to the application archive** — with one exception. The prep pack lands in `documents/applications/<company>_<role>/`; framework files are not edited, except appending user-approved STAR examples to `07-interview-prep.md` on explicit request.
|
||||
5. **Write only to the application archive** — with one exception. The prep pack lands in the archive folder derived in Step 1; framework files are not edited, except appending user-approved STAR examples to `07-interview-prep.md` on explicit request.
|
||||
|
||||
**The exception is `01-candidate-profile.md`.** Interview prep is where new facts surface most often: the user recalls a metric, corrects a scope, or fills in a STAR stub. When that happens, write the fact into the profile, as well as putting it in the prep pack. A fact recorded only in prep material reads as unsupported to a later drafting session and gets stripped from CVs as a fabrication. Prep files are not a substitute for the profile.
|
||||
|
||||
@@ -102,7 +102,7 @@ The page body is what makes a row worth clicking. Build it **only from stored da
|
||||
|
||||
1. **Fit summary** - a short section from `seen_jobs.json` fields: score, verdict, quick-fit level, first-seen and ranked dates. If the job is in the tracker, add the application timeline (date applied, channel, current status, dated notes from the `notes` column) and name the submitted documents from `cv_file`/`cover_letter_file` (filenames only - the documents themselves never sync). **When the status is `drafted`, write "drafted YYYY-MM-DD, not yet submitted" instead of a date applied, and call the files drafts rather than submitted documents** (page bodies are write-once - Step 4.3).
|
||||
2. **The posting** - WebFetch the job URL and write a readable digest: what the role is, key requirements, practical details (location, deadline, salary if stated). Retry a 403 with browser headers per `.claude/skills/job-application-assistant/09-web-research.md` first. If the fetch still fails or redirects to a listing page, write "Posting no longer available (checked YYYY-MM-DD)" - **never reconstruct a posting from memory**.
|
||||
3. **Links** - the posting URL; if `documents/applications/<company>_<role>/` exists locally, name it as the local archive path (plain text - the destination cannot link into the filesystem).
|
||||
3. **Links** - the posting URL; derive `<company>_<role>` by the **Subfolder naming** rule in `documents/README.md`, and if that archive exists locally, name its path (plain text - the destination cannot link into the filesystem).
|
||||
|
||||
Keep the page under ~40 blocks; this is a briefing, not a mirror of the posting.
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@ Follow these steps **in order**.
|
||||
3. **Without an argument:** list all rows whose status is not final (see **Tracker status vocabulary** below) as a numbered table (company, role, date applied, current status, deadline, days quiet, follow-ups sent) and ask which to update. The two derived columns come straight from existing data: **days quiet** counts from the row's `date` or the latest dated entry in `notes`, whichever is more recent; **follow-ups sent** counts the `followed up YYYY-MM-DD` markers in `notes`. If any open row is 10+ days quiet with fewer than two follow-ups sent, add one line under the table: "Some of these have gone quiet - want a follow-up draft? (Step 2b)". If every row is resolved, say so and stop.
|
||||
|
||||
**`drafted` rows are listed but never counted as quiet** - nothing was sent, so nobody is late replying. List them under their own heading ("Drafted, not yet submitted"), leave **days quiet** and **follow-ups sent** blank, and keep them out of the follow-up offer above.
|
||||
|
||||
**Deadline urgency is the one clock that does apply to a drafted row.** Show the `deadline` column when the row has one and leave it blank otherwise. Mark a deadline within 7 days with 🔥 and one that has already passed with ⚠, on the same 7-day threshold `/rank` Step 3 uses so the two commands never disagree. A passed deadline on a `drafted` row is the failure this column exists to catch - documents written, never sent, and now unsendable - so name it in one line under the table rather than leaving the user to compare dates. This changes nothing about the follow-up offer: a drafted row is still never chased, because nobody is late replying to something that was never sent.
|
||||
4. Derive the archive folder name: `documents/applications/<company>_<role>/` - lowercase, underscores for spaces (the convention documented in `documents/README.md`). Check whether the folder and an `outcome.md` already exist - if so, you are updating, not creating.
|
||||
|
||||
4. Derive the archive folder name: `documents/applications/<company>_<role>/` by the **Subfolder naming** rule in `documents/README.md`. Check whether the folder and an `outcome.md` already exist - if so, you are updating, not creating.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: >
|
||||
and preparing for interviews. Triggers on keywords like: job posting, job application, CV,
|
||||
cover letter, resume, interview prep, job fit, career, application, apply, ansøgning, stilling
|
||||
allowed-tools: Read, Glob, Grep, WebFetch, WebSearch, Bash, Edit, Write, AskUserQuestion
|
||||
framework_version: 1.3.3
|
||||
framework_version: 1.3.4
|
||||
---
|
||||
|
||||
# Job Application Assistant
|
||||
@@ -27,6 +27,7 @@ When the user provides a job posting (URL or text), follow this workflow:
|
||||
- Ask the user if they want to proceed with an application
|
||||
|
||||
### Step 2: Tailor CV
|
||||
- Before writing either document, derive `<company>_<role>` once by the **Subfolder naming** rule in `documents/README.md`; reuse that exact value for the CV, cover letter, and Step 3b archive path. If the rule says to stop because the derived name is empty, stop before creating any file.
|
||||
- Read the most relevant existing CV variant from `cv/` as a starting point
|
||||
- Follow the guidelines in `05-cv-templates.md`
|
||||
- Create `cv/main_<company>_<role>.tex` with tailored content
|
||||
|
||||
+14
-1
@@ -32,6 +32,20 @@ per-file diff commands.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A `/` in a company or role name no longer nests the application archive one level too deep**
|
||||
(jakob1379/ai-job-search#22). `Novo Nordisk A/S` derived
|
||||
`documents/applications/novo_nordisk_a/s_data_scientist/` - written and found by every command
|
||||
that derives the path, silently skipped by the two that enumerate it, so the application never
|
||||
appeared in `/html-report`'s dashboard and `/setup`'s calibration never learned from it. The
|
||||
**Subfolder naming** rule in `documents/README.md` now drops every character that is not a
|
||||
letter, digit or underscore (collapsing underscore runs, trimming the ends), and the derivation
|
||||
sites - `/apply`, `/outcome`, the direct application skill, `/gmail-sync`, `/interview`, and
|
||||
`/notion-sync` - cite that rule instead of paraphrasing it. An all-punctuation value that derives
|
||||
to an empty name now stops for user correction instead of writing into the archive root. The
|
||||
application assistant's `framework_version` moves 1.3.3 → 1.3.4. **Already-nested archives are
|
||||
not migrated**: an archive written under the old rule stays where it is until the user moves it;
|
||||
only newly derived names change. Thanks @jakob1379 for the report.
|
||||
|
||||
- **The `/html-report` dashboard now reads and renders the tracker's `deadline`** (follow-up to
|
||||
#319). The tracker gained a fourteenth `deadline` column and every other consumer (`/outcome`,
|
||||
`/upskill`, `/notion-sync`) was updated to know it, but the dashboard's Step 1 field
|
||||
@@ -95,7 +109,6 @@ per-file diff commands.
|
||||
every CV open in fullscreen presentation mode. `05-cv-templates.md`'s preamble copy stays
|
||||
in lockstep (framework_version 1.4.0 -> 1.4.1). Verified on moderncv 2.5.1: exit 0,
|
||||
exactly 2 pages, rendering unchanged.
|
||||
|
||||
## [1.5.0] - 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -116,6 +116,11 @@ A record of past job applications. Each subfolder is one application.
|
||||
You can maintain these folders by hand, or let the **`/outcome`** command do it: it records progress updates and final results conversationally, archives the submitted drafts and, if `/apply` has not already written it, the posting text, keeps `outcome.md` in the format below, and updates `job_search_tracker.csv` in the same step.
|
||||
|
||||
**Subfolder naming:** `<company>_<role>` — lowercase, underscores for spaces.
|
||||
Every character that is not a letter, digit or underscore is dropped (so `Novo Nordisk A/S`
|
||||
becomes `novo_nordisk_as`), runs of underscores collapse to one, and leading and trailing
|
||||
underscores are trimmed. If the derived name is empty, stop and ask the user for a company or
|
||||
role containing at least one letter or digit; do not create a file or directory. Every non-empty
|
||||
result is therefore a single path component whatever the posting contains.
|
||||
|
||||
Examples:
|
||||
```
|
||||
|
||||
@@ -29,9 +29,11 @@ APPLY = COMMANDS / "apply.md"
|
||||
OUTCOME = COMMANDS / "outcome.md"
|
||||
GMAIL_SYNC = COMMANDS / "gmail-sync.md"
|
||||
HTML_REPORT = COMMANDS / "html-report.md"
|
||||
INTERVIEW = COMMANDS / "interview.md"
|
||||
NOTION_SYNC = COMMANDS / "notion-sync.md"
|
||||
SKILL = REPO / ".claude" / "skills" / "job-application-assistant" / "SKILL.md"
|
||||
SCRAPER = REPO / ".claude" / "skills" / "job-scraper" / "SKILL.md"
|
||||
DOCS_README = REPO / "documents" / "README.md"
|
||||
|
||||
TRACKER_HEADER = (
|
||||
"date,company,sector,role,role_type,channel,status,contact_person,"
|
||||
@@ -383,5 +385,95 @@ class DeadlineSurvivesEveryWrite(unittest.TestCase):
|
||||
self.assertIn(needle, haystack, why)
|
||||
|
||||
|
||||
class ArchiveNameIsOnePathComponent(unittest.TestCase):
|
||||
"""`<company>_<role>` must derive a single path component.
|
||||
|
||||
`Novo Nordisk A/S` used to derive `novo_nordisk_a/s_<role>/`: every
|
||||
command that *derives* the path agrees and keeps working, while the
|
||||
two that *enumerate* `documents/applications/*/` (/setup Path A,
|
||||
/html-report's glob) silently skip the nested archive. The character
|
||||
rule lives in one place - documents/README.md's Subfolder naming
|
||||
block - and the derivation sites cite it rather than restating it
|
||||
(jakob1379/ai-job-search#22).
|
||||
"""
|
||||
|
||||
CASES = [
|
||||
(DOCS_README, "## applications/",
|
||||
"not a letter, digit or underscore is dropped",
|
||||
"the character rule is stated nowhere else; without it the naming "
|
||||
"convention leaves `/` untouched and the archive nests"),
|
||||
(DOCS_README, "## applications/",
|
||||
"single path component",
|
||||
"the sentence that says why the rule exists; without it the next "
|
||||
"edit simplifies the rule back to spaces-only"),
|
||||
(OUTCOME, "## Step 1: Load State and Identify the Application",
|
||||
"by the **Subfolder naming** rule in `documents/README.md`",
|
||||
"Step 1.4 is the derivation every other writer cites; paraphrasing "
|
||||
"the rule here is how the two copies drifted apart originally"),
|
||||
(APPLY, "### Requirement coverage (both documents)",
|
||||
"the same rule `/outcome` Step 1.4 uses",
|
||||
"CV and cover-letter filenames use the same unsanitised values; a "
|
||||
"`/` there sends the draft to a path lualatex never writes a PDF "
|
||||
"back to, and the Step 4 compile check fails on a phantom path"),
|
||||
(SKILL, "### Step 2: Tailor CV",
|
||||
"by the **Subfolder naming** rule in `documents/README.md`",
|
||||
"the /scrape path writes its documents before Step 3b consults /apply, "
|
||||
"so /apply's filename rule cannot protect it"),
|
||||
(GMAIL_SYNC, "## Step 2: Load State",
|
||||
"by the **Subfolder naming** rule in `documents/README.md`",
|
||||
"gmail-sync both locates and creates archives; its old spaces-only "
|
||||
"paraphrase would split state across two folders"),
|
||||
(INTERVIEW, "## Step 1: Load the Application Context",
|
||||
"by the **Subfolder naming** rule in `documents/README.md`",
|
||||
"interview must read the same archive /apply and /outcome wrote"),
|
||||
(INTERVIEW, "### 6. Logistics",
|
||||
"archive folder derived in Step 1",
|
||||
"interview must reuse its canonical read path when writing the prep pack"),
|
||||
(NOTION_SYNC, "## Step 5: Write the Detail Page",
|
||||
"by the **Subfolder naming** rule in `documents/README.md`",
|
||||
"notion-sync otherwise reports that the sanitized local archive is absent"),
|
||||
(DOCS_README, "## applications/",
|
||||
"If the derived name is empty",
|
||||
"dropping untrusted punctuation can produce no component at all, which "
|
||||
"would write files directly under documents/applications"),
|
||||
]
|
||||
|
||||
def test_the_rule_has_one_home_and_every_deriver_cites_it(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 derive(company, role):
|
||||
"""The Subfolder naming rule, executed exactly as documented:
|
||||
lowercase, underscores for spaces, drop every character that is
|
||||
not a letter/digit/underscore, collapse runs, trim the ends.
|
||||
(\\w is Unicode in Python 3, so Danish letters survive.)"""
|
||||
name = f"{company}_{role}".lower().replace(" ", "_")
|
||||
name = re.sub(r"[^\w]", "", name)
|
||||
name = re.sub(r"_+", "_", name).strip("_")
|
||||
return name or None
|
||||
|
||||
DERIVATIONS = [
|
||||
("Novo Nordisk A/S", "Data Scientist", "novo_nordisk_as_data_scientist"),
|
||||
("Acme", "Data Scientist / ML Engineer", "acme_data_scientist_ml_engineer"),
|
||||
("Ørsted A/S", "ML Engineer", "ørsted_as_ml_engineer"),
|
||||
# company/role reach the derivation from untrusted posting text
|
||||
# (apply.md Step 0), so `..` must not survive either
|
||||
("../..", "Data Scientist", "data_scientist"),
|
||||
("../..", "///", None),
|
||||
]
|
||||
|
||||
def test_documented_rule_yields_a_single_path_component(self):
|
||||
for company, role, expected in self.DERIVATIONS:
|
||||
with self.subTest(company=company, role=role):
|
||||
name = self.derive(company, role)
|
||||
self.assertEqual(name, expected)
|
||||
if name is None:
|
||||
continue
|
||||
self.assertNotIn("/", name)
|
||||
self.assertNotIn("..", name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user