cvme
Typeset resumes and cover letters from markdown.
uv sync
uv run cvme doctor # check the rendering environment
uv run cvme init my-docs # scaffold a project to fill in
cd my-docs && uv run cvme render resume
For a standalone install and a workspace outside the source checkout:
uv tool install cvme # or `uv tool install .` from this checkout
cvme init ~/Documents/job_hunt/2026
cd ~/Documents/job_hunt/2026
cvme render resume
To drive it from another project — a repo that holds your documents, say — add it as a dependency instead:
uv add cvme
uv run cvme render resume
Fonts and Typst templates ship inside the wheel, so a released install needs
nothing else on the machine. cvme doctor reports what it found.
Publishing is described in docs/releasing.md.
The generated application bundles live under applications/ in that
workspace by default. Set project.applications_dir in cvme.toml to put them
elsewhere; absolute paths and paths relative to the config file both work.
Status
Under construction, milestone by milestone. See docs/implementation-plan.md.
| Milestone | State |
|---|---|
M0 — scaffold, tooling, CI, cvme doctor |
done |
M1 — markdown grammar, parser, Typst renderer, cvme render |
done |
| M2 — page autofit, machine-readable output, cover letters | done |
M2b — project config, cvme init |
done |
M3 — fact corpus and the cvme verify guardrails |
done |
| M4 — job capture: ATS APIs, JSON-LD, manual paths | public HTTP + manual implemented; LinkedIn live-tested; browser pending |
| M5 — agent-driven tailoring | done |
M7 — cvme convert: an existing PDF resume back into markdown |
done |
M8 — cvme ats: check the rendered PDF the way a parser reads it |
done |
M9 — cvme prep and cvme apps: one directory per posting, tracked |
done |
M10 — pay and work-life read from the posting, sortable in cvme apps |
done |
M11 — cvme linkedin: one-way sync, audited against the live profile |
done; applied by paste, since LinkedIn's write API is partner-only |
Design
Markdown is the authoring surface. It parses to a typed intermediate representation, which a Typst template renders to PDF:
markdown ──parse──▶ typed IR ──render──▶ Typst source ──compile──▶ PDF
▲
style config (TOML)
cvme convert runs that pipeline backwards, so an existing PDF becomes a
source file this project can render, verify and tailor:
PDF ──extract──▶ styled lines ──recover──▶ structure ──emit──▶ markdown
Fonts are vendored and system fonts are disabled, and the PDF timestamp is pinned, so the same source produces byte-identical output everywhere.
Usage
A project is a directory with a cvme.toml, found by walking up from wherever
you run the command. Documents are named there, so you render by name and the
output lands in the configured directory:
uv run cvme render resume
uv run cvme render cover_letter
A recruiter who asks for your resume is not a posting: there is nothing to
tailor to and nothing to file. cvme send renders the untailored document once
per role title, named for the recipient rather than for the project:
uv run cvme send 'Senior Data Engineer'
uv run cvme send 'Data Engineer' 'Senior Data Engineer' 'Staff Data Engineer'
uv run cvme send 'Data Engineer' --md --dir outbox
Output goes to project.send_dir (send/ by default) as
<name>_<normalized_role>.pdf, using the same title normalisation the hunt
filenames use: grades and qualifiers are dropped, Sr. becomes senior, and
formal levels such as Senior, Staff and Principal are kept. Two titles that
normalise to one filename are refused rather than silently overwritten. The
PDF is rendered rather than copied, so a role name never ends up attached to a
stale render.
Start from a resume you already have. cvme convert reads a PDF and writes
the markdown grammar, so an existing document becomes an editable source file
in one step:
uv run cvme convert base.pdf # writes base.md beside it
uv run cvme convert base.pdf -o resume.md
uv run cvme convert base.pdf --stdout
Structure is recovered from geometry, because geometry is all a PDF records: a
bold line larger than the body is a section header, a date hard against the
right margin makes an entry header, a marker glyph followed by indented text is
a bullet, and an indented line without a marker continues the bullet above it.
Weight and slant become **bold** and *italic*, link annotations become
markdown links, and spaces are rebuilt from the gaps between glyphs — word
processors routinely emit no space characters at all. Read the result before
rendering it: a PDF records layout, not intent.
Capture a posting:
uv run cvme job fetch https://boards.greenhouse.io/acme/jobs/4012345
uv run cvme job fetch 'https://www.indeed.com/viewjob?jk=abc123'
uv run cvme job fetch https://www.linkedin.com/jobs/view/123
uv run cvme job add --html saved.html --url https://www.linkedin.com/jobs/view/123
pbpaste | uv run cvme job add --stdin --url https://x.example/1 --title T --company C
Capture tries ATS APIs for supported ATS URLs, then JSON-LD and site-specific
HTML for public pages. Saved HTML uses the same JSON-LD and site selectors
before falling back to page text. Entity-escaped JSON-LD descriptions are
converted to Markdown. If JSON-LD has no salary, capture reads the range and
any stated pay period from the description into salary; an explicit
structured salary takes precedence.
Both LinkedIn postings 4453268982 and 4457172708 were fetched successfully through public HTTP on 2026-09-05. Recorded job-bearing HTML fragments cover their descriptions, metadata, salaries, and cache reuse in offline tests. Sites may still require login or challenge completion; automatic browser capture remains pending, so use saved HTML or pasted text when public HTTP cannot retrieve the posting.
Search LinkedIn and Indeed in bulk and digest only postings not seen before:
[search]
blocked_companies = ["Raytheon", "Palantir"]
preferred_titles = ["data engineer", "platform engineer"]
excluded_titles = ["manager", "director"]
include_keywords = ["python", "spark", "databricks"]
exclude_keywords = ["security clearance"]
locations = ["New York, NY"]
remote_only = false
minimum_score = 2
request_interval_seconds = 5.0
max_detail_requests_per_run = 10
[[search.sources]]
site = "linkedin"
query = "data engineer"
location = "New York, NY"
pages = 2
posted_within_days = 7
[[search.sources]]
site = "indeed"
query = "data engineer"
location = "New York, NY"
pages = 2
posted_within_days = 7
Set remote = true on a source to request remote-only results from that board.
For “remote or NYC,” configure one NYC source and a second remote source; the
database deduplicates jobs returned by both.
Every outbound search or uncached detail fetch shares the configured minimum
interval. The detail-request cap leaves excess postings queued for the next
run; --limit can lower that cap for a one-off run but cannot raise it.
cvme digest # discover, parse, filter, and rank new postings
cvme digest --no-search # process queued, unparsed postings only
cvme digest --retry-errors # retry pages that were unavailable last time
Hard exclusions (company, title, keyword, location, and remote-only) are
applied before ranking. Preferred titles score three points; matching keywords,
an allowed location, and remote work score one each. Accepted postings are
written to jobs/, ready for cvme tailor. All identities and decisions live
in .cvme/jobs.sqlite3, so a posting remains deduplicated even when its search
URL or rank changes. Public search/detail pages can still return login or
challenge pages; those are recorded as errors and can be retried after using a
saved/manual capture path.
Tailor to a posting. The agent writes the documents, cvme verifies them, and only then renders:
uv run cvme tailor northwind # uses the configured agent
uv run cvme tailor northwind --dry-run # print the prompt, change nothing
uv run cvme tailor northwind --agent none # write the prompt to paste elsewhere
One posting, one directory
cvme prep runs the whole pipeline against a single posting: capture it,
score how well your corpus answers it, tailor every document, verify, render,
and write the background to read before the interview.
cvme prep https://boards.greenhouse.io/acme/jobs/4012345
cvme prep 'https://www.linkedin.com/jobs/view/123' --fit-only # score it, stop
pbpaste | cvme prep 'https://www.linkedin.com/jobs/view/123' --stdin \
--title 'Staff Data Engineer' --company Acme
cvme prep northwind --note 'shorter, led with the platform work'
It produces, or adds a version to:
hunts/2026/01_northwind-health_staff-data-engineer_2026-01-04/
posting.md the posting as captured, unedited
report.md the computed fit score, then the written background
apps/
index.md every version, and how each differs from the one before
v1/
jeff_brennan_staff_data_engineer.md
jeff_brennan_staff_data_engineer.pdf
jeff_brennan_staff_data_engineer_cover_letter.md
jeff_brennan_staff_data_engineer_cover_letter.pdf
v2/ # the next version, with the same filenames
Running it again on the same posting adds version 2 beside version 1 rather
than overwriting it, because the comparison worth having is against what you
nearly sent. --new forces a separate hunt instead. Filenames use the candidate name from the base document
and the normalized posting title: <name>_<title>.md and .pdf. Cover letters
add _cover_letter. Numeric grades, Specialist qualifiers, and trailing
department/location text are removed; explicit Senior, Staff, Principal and
Lead levels are preserved. Grade III is not automatically promoted to Senior.
The posting and document contents retain their original titles. Both prep
and tailor use this naming convention. prep stores revisions under v1/,
v2/, etc., and still recognizes legacy numbered files when choosing the next
version. hunt_stem is retained only for that legacy lookup.
To finish drafts written by hand or by an assistant in your current session,
put them in a directory named by document key (base.md and cover_letter.md
for a project with those documents), plus report.md for the role brief:
cvme prep path/to/posting.md --drafts-dir path/to/drafts --note 'Azure platform emphasis'
This imports the drafts without invoking an agent, applies the same citation
verification and PDF page limits, and records a new application version. The
computed fit/pay/work-life block is prepended to the imported brief. All selected
drafts must exist and be nonempty before a hunt is changed. Use --no-report
if no brief is supplied, or -d base to import only that document. Do not combine
--drafts-dir with --agent. Source drafts remain untouched.
The fit score
The score is computed, and it shows its working. A model asked to rate a fit
returns a number that reads well and cannot be checked, which is the failure
cvme verify exists to catch everywhere else.
The posting is read for terms from a packaged vocabulary of tools, practices
and domains, weighted by how often the posting names each one, and those terms
are looked for in your fact corpus and base documents. Skills carry 60 points,
the title 15, the stated years of experience 15, and the location 10. A posting
your own filters exclude scores zero and says which filter did it. Extend the
vocabulary for your field under [fit.extra_terms] in cvme.toml.
Personal preferences: WANTS.md
Skills fit answers “can I do this job?” Add a preferences file to also score
“do I want this job?” Configure it explicitly (relative to cvme.toml):
[fit]
wants = "WANTS.md"
WANTS.md contains YAML frontmatter for deterministic scoring and ordinary
Markdown notes for the written company/role assessment. For example:
---
version: 1
max_adjustment: 40
rules:
- name: Modern platform not mentioned
weight: -8
when: absent
any_of: [Databricks, Snowflake]
reason: Prefer a modern data platform; confirm the stack if unstated.
- name: Platform ownership
weight: 8
any_of: [platform engineering, data platform, developer experience]
reason: I want to build infrastructure other engineers use.
- name: AI startup
weight: -15
any_of: [AI platform, AI-powered]
requires_any: [startup, early stage, venture backed]
reason: Prefer organizations with a longer operating history.
---
# What I want next
Established healthcare organizations, platform ownership, and CI/CD.
Ask about testing standards and the balance of maintenance vs. new work.
Each rule needs a unique name, nonzero integer weight (-40 to +40),
reason, and nonempty any_of phrase list. Optional fields:
| field | default | meaning |
|---|---|---|
when |
present |
present: any phrase appears; absent: none appears |
scope |
posting |
Match company, title, description, or all three |
requires_any |
[] |
If supplied, at least one of these phrases must also appear |
unless_any |
[] |
Suppress the rule if any of these phrases appears |
Matching ignores case and punctuation and uses whole tokens (SAS does not
match Sassy). Each rule counts once regardless of repetitions or matching
aliases. Empty capture fields do not trigger absence rules. Missing mentions
are evidence about the captured posting, not proof about an employer. Context
guards help, but phrase rules do not infer an employer's industry, age, or
engineering quality. Prefer specific industry phrases over incidental words
like “finance,” and scope named-employer preferences to company.
cvme prep (including --fit-only) adds the summed preference adjustment,
capped at ±max_adjustment (default 40), to the existing alignment score and
clamps the result to 0–100. Exclusion filters still force zero. Reports show
alignment, the adjustment, and every matched rule with its phrases and reason.
The stored application fit and its band use this overall score. Work-life
remains a separate assessment; digest discovery/filter scores are unchanged.
With no configured file, scoring behaves as before. A configured missing or
invalid file raises an error rather than silently dropping your preferences.
Only the report prompt receives WANTS notes. They never enter the skills
corpus, verification evidence, or resume/cover-letter prompts. Do not add
WANTS.md to project.facts. Existing reports/scores are not rewritten;
use prep --fit-only to review a posting under your current preferences.
What that buys is the second half of report.md: which of the posting's own
requirements your corpus answers, and which it does not.
**Fit 58/100 (fair)**
| component | earned | of | why |
|---|---|---|---|
| skills | 30 | 60 | 7/14 terms, weighted by mention count |
| title | 15 | 15 | matches 'data engineer' |
| experience | 15 | 15 | asks 6y, corpus evidences 7y |
| location | 5 | 10 | no preferred locations set |
**Answered.** databricks (2), airflow, data quality, kubernetes, python, sql
**Not answered.** claims (2), delta lake, healthcare, pyspark, rust, terraform
**Pay** $150k-190k (as stated: $150,000 - $190,000 per year)
**Work-life 50/100 (busy)**
| signal | worth | what it predicts |
|---|---|---|
| unlimited pto | -12 | no accrued balance to pay out, and no floor on what is actually taken |
| fast paced | -8 | the pace is advertised before the work is, which is a ranking |
| parental leave | +10 | the leave is paid and its length is written down |
The rest of the report is written by the agent from the posting and your
corpus, with anything it recalls rather than reads kept under its own
## From general knowledge (unverified) heading. The score is never asked of
the agent, so nothing in the report asserts a number that cannot be recomputed.
What the posting says it pays, and what it says about the hours
Both are read from the posting and reported beside the fit, because a strong match that pays under your current job, or that advertises itself with "we are a family" and "unlimited PTO", is not the one to spend the evening on.
Pay is annualised so the column compares: a range, a single figure, or an hourly rate all become one number, and the words it was read from are kept beside it. A figure is only believed where the posting marks it as money, so "5+ years" and "10,000 patients per year" are not read as salaries.
The work-life score is the same argument as the fit score. Asking a model what a company is like to work at produces an answer worth nothing; what can be checked is the vocabulary the posting chose. It starts at 60, subtracts what each cost phrase is worth, adds what each lift is worth, and names every phrase it found:
work-life 34/100 (busy)
-12 unlimited pto: no accrued balance to pay out, and no floor on what is actually taken
-10 many hats: the role is undefined by design, and expands to fit the gaps
-8 fast paced: the pace is advertised before the work is, which is a ranking
+10 parental leave: the leave is paid and its length is written down
Costs include 996, we are a family, rockstar, unlimited PTO,
wear many hats, lean team, full stack, fast paced, hustle,
always on, crunch, competitive salary and comfortable with ambiguity.
Lifts include four day work week, no on call, collective bargaining,
25 days of PTO, paid parental leave, sabbatical, core hours,
blameless postmortems, company shutdown and sustainable pace. A phrase
counts once however often it appears, and a promise is not charged for what it
promises to avoid: "no on-call" does not also score as "on call". Where a
posting says nothing either way the band is unstated, because silence is not
the same as sixty.
Add your own tolerances in cvme.toml; naming a packaged phrase replaces its
weight rather than doubling it:
[culture.extra_costs]
"relocation required" = 12
[culture.extra_lifts]
"no travel" = 8
Tracking what you have not sent
cvme apps list # prepared and unsent, best fit first
cvme apps list --sort salary # or wlb, age, waiting, updated, company, ...
cvme apps list --sort age --reverse # newest first instead of oldest
cvme apps list --status applied,interviewing
cvme apps list --all
cvme apps show northwind # every version, and what changed between them
cvme apps rescan # re-read every posting for pay and hours
cvme apps submit northwind # sent: file it under applied/
cvme apps status northwind interviewing --note 'call tuesday'
cvme apps status northwind rejected
prepared, not yet sent, by fit
fit company title salary work-life where age status v
89 strong Acme Analytics Senior Data Engineer - 0 grind onsite 6d prepared 2
82 strong Mount Sinai Senior Data Engineer - unstated hybrid 4d prepared 1
81 strong Hypergrowth Labs Founding Data Engineer $140k-170k 0 grind hybrid 2d prepared 1
78 strong Northwind Health Staff Data Engineer $185k-215k 100 calm remote 1d prepared 1
4 prepared median pay 200k of 2 stated mean work-life 33 of 3 stated
--sort takes fit, salary, wlb, age (longest sitting first), waiting
(longest since it was sent), updated, company, title, status and
versions; --reverse flips whichever it is. --columns picks and orders the
cells, from fit, company, title, salary, wlb, age, waiting, status, versions, where, location, note, slug, url, directory. Where the terminal is too narrow
to hold the default set, the rightmost columns are dropped rather than every
column squeezed, and the listing says which.
cvme apps rescan re-reads each captured posting.md for pay and hours and
updates the table -- after the lexicon changes, or for applications prepared
before those columns existed. It leaves the fit score alone: that is measured
against your corpus and belongs to the run that produced the documents.
Statuses are prepared, applied, interviewing, offer, rejected and
withdrawn. Refiling moves the whole directory, so what is still sitting at
the top level of the year is exactly what is still unsent. The index over it
lives in .cvme/jobs.sqlite3 beside job discovery, because a posting found by
cvme digest and an application prepared from it are the same job.
A reference can be a full slug, part of one, or a company name; where it is ambiguous the command says what it could have meant rather than guessing.
Check the artefact rather than the source. cvme ats renders the document,
reads the PDF back with the converter, and reports every place where the
machine reading differs from what was written:
uv run cvme ats resume # render, read back, report
uv run cvme ats out/resume.pdf --json
The checks are the questions a parser asks: is there text, does the letterhead yield an address, do the dates parse, do the bullets separate, are the sections named something it maps to a field, and does the structure it recovers match the structure you wrote. That last one is the whole question asked once, and it is why this exists: markers that are drawn rather than typed look perfect and extract as one undivided paragraph.
Verification checks that every number is sourced and the prose does not read as generated:
uv run cvme verify # every configured document
uv run cvme verify resume
uv run cvme verify draft.md --facts facts/metrics.md --json
Any markdown file works without a project:
uv run cvme render path/to/resume.md -o out/resume.pdf
uv run cvme render resume.md --style compact --png
uv run cvme render resume.md --set leading=6.0 --set margin_x=50
uv run cvme render resume.md --watch
uv run cvme render resume.md --max-pages 1 # tighten until it fits
uv run cvme render resume.md --pdf-standard ua-1 # accessibility-tagged
uv run cvme render cover_letter.md --template cover_letter --style letter
--max-pages is enforced: the renderer walks a bounded ladder of density
adjustments and fails with a diagnostic naming the longest material if the
floors are reached. See docs/machine-readability.md
for how the output reads to a parser.
Styles
A style is a TOML file of resolved numbers in
src/cvme/style/presets. Every preset is one column,
standard section names, real bullet glyphs and extractable text -- the things a
parser reads -- so the choice between them is a choice about the human reader
only.
| Preset | Body | Name | The idea |
|---|---|---|---|
sans |
Source Sans 3 | Fira Code | One accent, monospace section headings, a rule under the letterhead and a hairline under each section. |
serif |
Source Serif 4 | Fira Code | The same design in a text serif, with the leading it needs. |
standard |
Carlito | Fira Code | The reference layout. No rules, no colour, no accent. |
compact |
Carlito | Fira Code | standard, tightened, for a page and a half of content. |
airy |
Carlito | Fira Code | standard, opened up, on a two-page budget. |
sans and serif are the same set of decisions in two type families, which is
why there are two of them and not six: a variation that only changes the font
is a font argument, not a style.
Accents
An accent is the one colour a document spends. Setting it moves the name, the section headings, the rule under the letterhead and the links together, because the presets leave all four unset and falling back to it:
uv run cvme render base --style sans --accent navy
uv run cvme render base --style sans --accent '#8a1538'
Named colours are in src/cvme/style/color.py:
black, graphite, slate, navy, teal, forest, maroon, burgundy, plum, rust. A hex
value is checked against a 4.5:1 contrast floor and refused with the measured
ratio if it fails, because a brand colour chosen for a white logo on a coloured
field is routinely too pale to set type in.
The presets ship near-black, so a document is monochrome until asked otherwise.
To match a company you are applying to, name them in cvme.toml and cvme prep
does the rest:
[accent]
default = "graphite"
[accent.companies]
"Northwind Analytics" = "#004b87"
"Ridgeway Health" = "forest"
Every other knob a preset sets is a field on Style, and any of them overrides
from the command line:
uv run cvme render resume.md --style sans --set section_rule=0
uv run cvme render resume.md --style serif --set link_underline=true
Colour fields fall back rather than default: an empty name_color is accent
before it is ink, and an empty date_color is muted before it is ink. A
preset therefore reads as the short list of decisions it actually makes.
The authoring grammar is src/cvme/md/GRAMMAR.md.
tests/fixtures/resume.md is a complete worked example.
Keeping LinkedIn in step
base.md is the source of truth; the profile is a projection of it. cvme linkedin reports what has changed since the last time you pushed, so an edit
to one bullet is one field to update rather than a profile to re-read.
cvme linkedin diff # what has changed since the last recorded sync
cvme linkedin sync # write out/linkedin-changeset.md
cvme linkedin record # mark the current documents as applied
cvme linkedin status
Because the paste is manual, cvme linkedin check audits the result against
what LinkedIn actually holds and exits non-zero on drift, so it works as a CI
or cron check.
cvme linkedin check ~/Downloads/Profile.pdf # More > Save to PDF: one click
cvme linkedin check ~/Downloads/export.zip # the data export: slower, complete
cvme linkedin check <source> --record # record what LinkedIn says
cvme linkedin check <source> --show # print what cvme read, and stop
The quick way is your profile's own More > Save to PDF, which downloads
immediately; cvme already reads PDF resumes, so it is the cvme convert
pipeline pointed at a different document. The data export (Settings & Privacy
Data Privacy > Get a copy of your data) takes a few minutes and is the one that can vouch for your skills list -- the PDF prints only "Top Skills", so it declines to check them and says so rather than reporting the rest as missing.
Findings are missing (in your documents, not on LinkedIn), stale (on
LinkedIn, out of date) or extra (on LinkedIn, not in your documents). The
first two fail; extra only fails under --strict, because a resume drops an
old job for space and the profile keeping it is not drift.
There is also an opt-in browser capture, which reads your own profile from a local Chromium you are signed in to:
uv sync --extra browser && uv run playwright install chromium
cvme linkedin login # a window opens; you sign in
cvme linkedin check --browser
LinkedIn's user agreement prohibits automated access and does not carve out
your own profile, so this is yours to opt into and the account risk is yours.
cvme keeps it honest rather than quiet: the browser is visible, you type your
own credentials and clear your own MFA, there is no detection evasion of any
kind, and the session reads the profile LinkedIn resolves for your account and
refuses anything else. cvme linkedin logout deletes the stored session.
A capture reports a status per section -- complete, empty, partial,
unavailable -- and only the first two can support "missing from LinkedIn". A
timeout says "could not check". cvme linkedin capture prints what was
recovered, which is how you check the selectors against the real page.
Anonymous fetching of other people's pages stays absent. hiQ Labs v. LinkedIn ended in 2022 with a $500,000 judgment against hiQ for breaching the user agreement and an injunction to delete what it had taken -- the well-known ruling that scraping public pages is not a CFAA crime left the contract claim untouched, and LinkedIn won that one.
The changeset is a file you paste from: every changed field, in the order
LinkedIn's editor presents them, with the new text in a fenced block and where
in the UI it goes. The first run lists everything, since cvme has not recorded
a profile yet; after record, only what moved.
There is no automated push, and cannot be. LinkedIn's Profile Edit API is
the only way to write a profile section, and it is restricted to
partner-approved developers. The self-serve tier grants openid, profile,
email and w_member_social, none of which writes a profile section. Driving
a logged-in browser instead would violate LinkedIn's terms, so cvme does the
part that is actually hard -- knowing what changed -- and leaves the pasting to
you.
A profile has no page to fit, so an optional linkedin.md beside the resume
carries the longer copy. It is a patch, not a second resume: write only the
sections and entries that should read differently, and everything else comes
from base.md. It needs no configuration -- the file being there is what turns
it on, and base/linkedin.md.example is a template to rename.
---
headline: Staff Data Engineer | Streaming platforms | Python, Spark, Databricks
---
## Experience
### Staff Data Engineer @ Northwind Analytics | Jul 2023 – Present
- Every bullet the page could not fit, up to LinkedIn's 2,000 characters
Entries match on role, organisation and start date, so an overlay keeps matching the day "Present" becomes a real end date.
Over-long fields are refused rather than truncated, and sections cvme has no LinkedIn field for are reported rather than dropped. The full design and the mapping table are in docs/linkedin-sync.md.
Guardrails
cvme verify exists because a prompt asking a model not to invent numbers
fails silently, and you find out after you have applied.
- Every quantitative claim must be sourced. Numbers are extracted from the
document and normalised, so
100k,100,000and$100Kcompare as one value, then matched againstfacts/metrics.md,facts/skills.mdand your base resume. Matching is exact:$98kin the corpus does not license$100kin the output, because rounding up is the failure this catches. A bullet may cite its source with<!-- fact: id -->, and is then checked against that fact specifically. - Prose is linted for generated register. Em dashes, the "not just X, it's
Y" construction, a list of stock phrases, weak bullet openers, and
rule-of-three cadence. Rules live in
src/cvme/verify/rules.toml.
Exit codes are distinct so a script can tell failures apart: 1 bad input,
2 page budget, 3 verification and cvme ats, 4 fetch, 5 agent,
6 conversion, 7 a missing or inconsistent hunt.
cvme tailor treats verification as a gate, not a report. A document that
invents a metric is never rendered, and any PDF from an earlier run is removed
rather than left sitting beside a rejected draft.
A generated draft ends with a ## Gaps section listing every requirement the
corpus could not answer. It is dropped at the render boundary and never reaches
a PDF: it is written for you, and it is the last thing you would send.
Generated quantitative claims must carry an <!-- fact: id --> citation.
Matching retains the measured subject and qualifier, so 14 facilities does
not license 14 engineers, and 100k, ~100k, and 100k+ are distinct. Fact
checking fails closed when no corpus is configured; use --no-facts for an
explicit prose-only check or tailor --no-verify to bypass the generation gate.
Job postings are untrusted input. Their text is wrapped in a per-run random boundary with instructions to treat it only as reference data. Automated agents run in a temporary staging directory containing only the assembled prompt, and their draft is copied to the application directory only after the process exits. The same holds for the report: the agent sees the posting, the corpus, and nothing else, and it has no network access to check anything it recalls.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cvme-0.1.0.tar.gz.
File metadata
- Download URL: cvme-0.1.0.tar.gz
- Upload date:
- Size: 902.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
42ac98ad4315453051a994fc181beac7c30f78ef93d95672174bb61dbd0d7012
|
|
| MD5 |
0185999194145c4240e0acf2626cb94a
|
|
| BLAKE2b-256 |
3a56e28cb61c673180bf27bbefa49aeaa12928508f7e74b6787d2784752602bd
|
Provenance
The following attestation bundles were made for cvme-0.1.0.tar.gz:
Publisher:
release.yml on jeffbrennan/cvme
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cvme-0.1.0.tar.gz -
Subject digest:
42ac98ad4315453051a994fc181beac7c30f78ef93d95672174bb61dbd0d7012 - Sigstore transparency entry: 2812695831
- Sigstore integration time:
-
Permalink:
jeffbrennan/cvme@8149a81ad82396eca116dff5bd6cf2a095cd2d3e -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/jeffbrennan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@8149a81ad82396eca116dff5bd6cf2a095cd2d3e -
Trigger Event:
push
-
Statement type:
File details
Details for the file cvme-0.1.0-py3-none-any.whl.
File metadata
- Download URL: cvme-0.1.0-py3-none-any.whl
- Upload date:
- Size: 866.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c8f7db920b287b31ca1c3eb072ea9fba8e6b345054a03be550aca0483d2c3ef7
|
|
| MD5 |
411eeb3ad87b3a834f71bc0b56b236a0
|
|
| BLAKE2b-256 |
18dd695129788f85d8a94fbf652f9f5361f03f305537d8aae7567ef9bab6e907
|
Provenance
The following attestation bundles were made for cvme-0.1.0-py3-none-any.whl:
Publisher:
release.yml on jeffbrennan/cvme
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cvme-0.1.0-py3-none-any.whl -
Subject digest:
c8f7db920b287b31ca1c3eb072ea9fba8e6b345054a03be550aca0483d2c3ef7 - Sigstore transparency entry: 2812695854
- Sigstore integration time:
-
Permalink:
jeffbrennan/cvme@8149a81ad82396eca116dff5bd6cf2a095cd2d3e -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/jeffbrennan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@8149a81ad82396eca116dff5bd6cf2a095cd2d3e -
Trigger Event:
push
-
Statement type: