| name | cv-structure |
| description | Write the CV markdown that will be PDF'd and reviewed by the Critico. First read the user's ORIGINAL CV in `$JHT_HOME/profile/sources/` to mirror their voice and match the register to the industry (the tech defaults — "Technical Skills: Python", metric+tech-in-parens — are wrong for hospitality/sales/care/creative). Six fixed sections, max 2 pages, every claim traceable to `candidate_profile.yml`/sources (zero invenzioni — T10). Languages appear ONCE (Header only). Cover Letter only if the JD explicitly asks, with its own "Cover Letter" title (never "CV"). Owned by the Scrittore. Pair with `application-flow` (claim + path) and `critic-loop` (review iterations). |
cv-structure — the canonical CV layout
Output goes to $JHT_USER_DIR/cv/CV_<Candidato>_<Company>.md (then PDF via pandoc/typst). Path rule: application-flow skill — never write the final CV under $JHT_AGENT_DIR (that's scratch only, T11).
<Candidato> = Nome_Cognome from the profile. <Company> = company name normalised PascalCase, no spaces or slashes (e.g. Acme_Corp → AcmeCorp).
Before you write: read the source, match the register (W-05, 2026-07-01)
candidate_profile.yml is flattened data, not voice. Before drafting, read the user's original CV in $JHT_HOME/profile/sources/ (the PDF/doc they uploaded) plus $JHT_HOME/profile/summaries/. Two reasons:
- Voice & distinctive detail. The original carries what makes the candidate them — the story, the named context, the human register. A CV rebuilt from the
.yml alone comes out cold and interchangeable. Mirror their phrasing and keep the concrete details that give weight (not a bare "VIP guest handling" but the Nobu / De Niro–founded context behind it).
- Register by industry. Read
industry + seniority_target from the profile. This skill's defaults (metric-in-bold + tech-in-parens, a "Technical Skills" table with Python, Go, Bash) are tuned for tech / engineering roles. For client-facing / non-tech profiles they are WRONG — they sterilise the exact warmth that sells the candidate. Adapt:
| Profile family | Section 4 name | Bullet style | About Me |
|---|
| Tech / engineering | Technical Skills (Languages, Backend, Data, Infra) | metric in bold + tech in parens | factual, numbers |
| Client-facing / hospitality / retail / care | Skills & Strengths (NO programming languages) | concrete result + context in parens (place, scale, clientele); warmth allowed | credible + a human touch, mirroring the original |
| Sales / business | Core Skills | metric in bold (revenue, targets) + tools in parens | outcome-driven |
| Creative / design | Skills & Tools + portfolio link | outcome + medium in parens | voice-forward |
Never put a "Technical Skills / Languages: Python…" table on a hospitality, sales or care CV. Tone is set by both the JD (company type, below) and the candidate's industry register.
The 6 sections (fixed order, max 2 pages)
| # | Section | Length | Content |
|---|
| 1 | Header | 4-6 lines | Name, role title aligned to the JD, contacts (email/phone/LinkedIn/GitHub), languages (CEFR) |
| 2 | About Me | 2-3 lines | Concrete credibility. NEVER generic phrases ("passionate about", "results-driven") |
| 3 | Experience | 4-5 sub | Each sub = one experience, mapped to one specific JD requirement. Bullets: metric + tech |
| 4 | Technical Skills | 1 table | Matches JD keywords. Only tech actually documented in the profile. |
| 5 | Education | 2-4 lines | Exact titles from the profile. Don't apologise for missing degrees. |
| 6 | Side Projects | 0-3 sub | Only if they reinforce the JD fit. Skip the section entirely if nothing fits. |
Section 1 — Header
# <Nome Cognome>
**<Role title aligned to the JD>** · <City, Country>
✉️ <email> · 📱 <phone> · 🔗 linkedin.com/in/<handle> · 💻 github.com/<handle>
🗣 <Lingua1 (level)>, <Lingua2 (level)>
Adapt the role title: if the JD says "Backend Engineer (Python)" use that, not the generic profile target. Stay truthful — never claim a seniority you don't have.
Languages appear ONCE. Put spoken languages on the Header 🗣 line and nowhere else — do NOT restate them at the end of About Me and do NOT add a "Languages" row in the Skills table. (The beta-3 betaD CVs listed the 4 languages 3× on one page: Header + About + Skills. One place only.) Exception: if the JD makes a specific language a core selected requirement, keep it in Skills and drop the generic list from the Header — still one place.
Section 2 — About Me
2-3 lines. The user is a real person who has done real things; show that in 30-50 words. Banned phrases:
| ❌ Banned | ✅ Replace with |
|---|
| "Passionate about " | a fact: "5 years building in production" |
| "Results-driven professional" | a number: "Reduced p95 latency 320ms → 110ms across 3 services" |
| "Looking for an opportunity to grow" | drop entirely; the application itself signals that |
| "Detail-oriented team player" | give an example or omit |
Section 3 — Experience
The hardest section. Each sub-block is one experience mapped to one JD requirement.
### <Role> @ <Company> — <Mar 2022 – present>
- **Reduced cold-start time 4.2s → 0.8s** rewriting the bootstrap layer (Python, asyncio, uvloop)
- **Shipped 3 customer-facing data products** owning the full stack (FastAPI, Postgres, dbt, Airflow)
- **Mentored 2 junior backend engineers** through their first production incidents
Bullet rules:
- Metric in bold at the start (number, %, time, scale)
- Tech in parens at the end of the bullet
- Action verb as the first word (see banned/allowed list below)
- One line per bullet. If it wraps, you're cramming too much.
- 3-5 bullets per experience. Fewer = the experience looks thin; more = noise.
Action verbs
| ✅ Use | ❌ Banned |
|---|
| Built, Architected, Shipped, Engineered, Reduced, | learned, studied, assisted, |
| Migrated, Designed, Owned, Mentored, Scaled, Cut | helped, was involved in, |
| participated in, was responsible for |
Banned verbs signal a junior/uncertain voice. Use the active list even when the role was junior — focus on what you delivered, not what you did.
Section 4 — Skills (name + content by register)
The section title and contents depend on the profile family — see "read the source, match the register" above. Tech role → Technical Skills (table example below). Client-facing / non-tech → rename it (Skills & Strengths / Core Skills) and never list programming languages.
A 2-column markdown table that mirrors the JD's keyword list. Only skills/tech the profile actually documents. Inventing a tool you don't know is an instant fail in the Critic's review (and a real-world recruiter kill). The Python, Go, Bash example below is tech-only — do not paste it onto a non-tech CV.
| Area | Stack |
|-------------------|--------------------------------------------------------|
| Languages | Python, Go, Bash |
| Backend | FastAPI, Django, gRPC |
| Data | PostgreSQL, Redis, dbt, Airflow |
| Infra | Docker, GitHub Actions, AWS (EC2, S3, RDS) |
Categories should match what the JD emphasises. If the JD never mentions infra, drop or compress that row.
Section 5 — Education
### <Degree>, <Institution> — <Year>
<one-line note: GPA only if > 28/30 ≈ 3.5/4, thesis title only if relevant to the JD>
If the candidate has no degree:
- Don't apologise ("currently pursuing", "self-taught instead of"). Apologising signals weakness.
- List relevant certifications, bootcamps, online programs as their own entries.
- Lean on the Experience section to carry weight.
Section 6 — Side Projects (optional)
Include ONLY if a project clearly reinforces the JD fit. Same bullet pattern as Experience.
### <Project name> — <github link>
- **<metric / outcome>** (<tech stack>)
- One-line description of what it does and why it's relevant
If nothing fits, skip the section entirely. Empty padding signals lack of substance.
Tone by company type (from JD signals)
| Company type | Tone | Signals in JD |
|---|
| Startup | Confident, ownership-heavy, direct, action verbs first | "fast-paced", "wear many hats", "early-stage", small team size |
| Corporate | Professional, structured, process-aware | "stakeholders", "cross-functional", larger team, well-defined process |
| Fintech / regulated | Compliance-aware, precise, cite frameworks (PCI-DSS, SOC 2, ISO 27001) | mentions of audits, regulators, compliance teams |
| Agency | Versatile, client-facing, breadth over depth | "varied projects", "client-facing", "delivery" |
Don't overdo it — tone is a colour, not a costume. The bullets stay factual either way.
Cover Letter (only if the JD asks)
Default: don't write one. Token + time saved. Write it ONLY if the JD explicitly mentions it ("please include a cover letter", "tell us why you want this role").
Length: 250-400 words. Path: $JHT_USER_DIR/allegati/CoverLetter_<Candidato>_<Company>.{md,pdf}.
PDF title: "Cover Letter", NEVER "CV". The cover letter is its own document — generate it with its own title metadata. Do NOT reuse the CV pandoc command (which hardcodes --metadata title="CV …"): that is exactly the bug that shipped 31 beta-3 cover letters whose document title read "CV betaD".
pandoc "$COVER_MD" -o "$COVER_PDF" \
--pdf-engine=wkhtmltopdf \
--metadata title="Cover Letter $CANDIDATO"
The # <Nome Cognome> heading at the top of the cover-letter markdown is the candidate's name (not "CV") — keep it, and let the title metadata above say "Cover Letter".
Opening (direct, NOT "I am writing to express my interest"):
"I'm applying for <role> because <3-4 concrete proofs that match the JD>."
Middle (1-2 paragraphs):
- One specific past achievement that maps to the JD's main pain point
- One thing you noticed about the company that goes beyond their landing page
Close:
- One forward-looking line: what you'd want to do in the first 90 days
- "Happy to discuss this in more detail."
Banned in cover letters:
- "I am writing to express my interest…" → starts with effort and ends with nothing
- "Please find attached my CV…" → it's an application, of course it's attached
- "I would be honoured…" → corporate cliché
PDF generation — engine + atomic write + DB UPDATE (W-03, bug #26)
Engine: wkhtmltopdf (NON typst, NON fpdf2)
Decisione tecnica 2026-05-18 dopo indagine "CV estetica semplificata":
wkhtmltopdf 0.12.6 (Qt 5.15.8) → engine ufficiale, già installato
nel container. Produce CV professionali HTML+CSS, 2 pagine, ~30 KB
(output identico ai CV "belli" del 16 maggio).
- ❌ NON usare
--pdf-engine=typst: typst non è disponibile in
pandoc 2.17 del container (richiederebbe pandoc 3.x). Errore
storico nella skill, segnalato 2026-05-18.
- ❌ NON usare
pdf_gen.py (fpdf2) per CV: è solo fallback
minimalista 80% casi semplici. Per CV user-facing produce layout
spartano 1 pagina, niente CSS, niente spacing fine.
The historical anti-pattern: generate the PDF straight into
$JHT_USER_DIR/cv/, then run db_update.py application --cv-pdf-path ... separately. If the Sentinel killed the Writer between the two
steps (EMERGENZA freeze 2026-05-17 04:43), the PDF stayed on disk but
the DB had cv_pdf_path=NULL. Sisal 7.5/10 PASS became "CV da
scrivere" on the dashboard for the user — invisible top opportunity.
Fix: tempfile + size gate + atomic mv + single-shot UPDATE. If the
UPDATE fails, remove the final file so we don't leave an orphan.
SRC_MD="$JHT_USER_DIR/cv/CV_${CANDIDATO}_${POSITION_ID}_${COMPANY_SLUG}_${TITLE_SLUG}.md"
FINAL_PDF="$JHT_USER_DIR/cv/CV_${CANDIDATO}_${POSITION_ID}_${COMPANY_SLUG}_${TITLE_SLUG}.pdf"
TMP_PDF="$(mktemp -t cv_${POSITION_ID}.XXXXXX.pdf)"
if ! command -v wkhtmltopdf >/dev/null 2>&1; then
echo "[cv-structure] ABORT preflight: wkhtmltopdf non disponibile."
echo " Engine alternativi accettabili: weasyprint (pandoc --pdf-engine=weasyprint)."
echo " NEVER fallback a pdf_gen.py / fpdf2 per CV (output brutto)."
echo " Riportare il problema al Capitano via [REPORT] e ABORT."
exit 2
fi
pandoc "$SRC_MD" -o "$TMP_PDF" \
--pdf-engine=wkhtmltopdf \
--metadata title="CV $CANDIDATO"
size=$(stat -c%s "$TMP_PDF" 2>/dev/null || stat -f%z "$TMP_PDF")
if [ ! -s "$TMP_PDF" ] || [ "$size" -lt 20000 ]; then
echo "[cv-structure] ABORT post-render: PDF $size B sospetto (atteso ≥20 KB)."
echo " Probabile engine sbagliato (fpdf2 minimalista invece di wkhtmltopdf)."
rm -f "$TMP_PDF"
exit 3
fi
producer=$(python3 -c "
from pypdf import PdfReader
import sys
try:
r = PdfReader('$TMP_PDF')
m = r.metadata or {}
print(m.get('/Producer', ''))
except Exception as e:
print('?'); sys.exit(1)
" 2>/dev/null)
case "$producer" in
*Qt*)
:
;;
*)
echo "[cv-structure] ABORT post-render: Producer='$producer' (atteso 'Qt 5.x.x')."
echo " L'engine reale NON era wkhtmltopdf — output non professionale."
rm -f "$TMP_PDF"
exit 4
;;
esac
mv "$TMP_PDF" "$FINAL_PDF"
if ! python3 /app/shared/skills/db_update.py application "$POSITION_ID" \
--cv-pdf-path "$FINAL_PDF" --written-at now; then
echo "[cv-structure] UPDATE DB fallita, rimuovo PDF per non lasciare orfani"
rm -f "$FINAL_PDF"
exit 1
fi
Exit codes:
0 → CV OK, DB aggiornato, pronto per critic-loop
2 → preflight FAIL (engine non disponibile) — segnala al Capitano
3 → post-render FAIL (size < 20 KB, output minimalista) — engine sbagliato
4 → post-render FAIL (Producer != Qt) — engine sbagliato
1 → DB UPDATE FAIL (rollback file)
Il Dottore via cv-disk-audit healthcheck (bug #18) ricollega eventuali
orfani disk↔DB; in più ora segnala anche i CV con Producer non-Qt come
"engine sbagliato — rigenerare".
Pre-generation status gate (W-04, bug #26)
Before running pandoc, verify the position is still scoring-grade.
Sometimes the Analyst marks excluded after the Writer has claimed
the position (race condition) and the Writer keeps writing — 3 CV
sprecati su Canonical ContainerImages / K8s / Deloitte nei dump del
2026-05-17.
status=$(python3 /app/shared/skills/db_query.py position "$POSITION_ID" --field status)
case "$status" in
excluded|rejected)
echo "[cv-structure] position #$POSITION_ID is $status, skipping CV generation"
exit 0
;;
esac
Hard rules
- Zero invenzioni. Every metric, every tech, every project must trace back to
candidate_profile.yml or the user-provided sources. Inventing fails the Critic and is a fireable offense in real life. T10.
- Read the original first. Before writing, read the user's uploaded CV in
$JHT_HOME/profile/sources/ and mirror voice + register + distinctive detail. The .yml is data, not voice — a CV built from it alone comes out cold.
- Register fits the industry. A hospitality / sales / care / creative CV must NOT read like a dev CV — no "Technical Skills: Python", no sterile metric-only bullets where warmth is the selling point. See the register table.
- Languages once. Header only, never also in About + Skills.
- Tailor per JD. The same candidate gets a different CV per role: different About, different Experience emphasis, different Skills order. Generic CVs fail the score gate.
- One requirement → one experience block. If the JD has 5 requirements and your Experience section maps to 2, you're not telling the right story.
- Max 2 pages. Recruiters skim. If page 3 exists, cut.
Anti-patterns
- ❌ Generic About Me ("passionate developer with strong skills") — instant kill in the Critic's review.
- ❌ Skills table with tech not documented in the profile — invention, T10 violation.
- ❌ Apologising for missing degree / years — signals weakness.
- ❌ Same CV across multiple JDs — score gate punishes generic CVs.
- ❌ Cover letter when not asked — wasted tokens, longer review cycle, no value.
- ❌ Cover letter whose PDF title reads "CV …" — reused the CV pandoc command; use
--metadata title="Cover Letter $CANDIDATO".
- ❌ Same fact repeated across sections (languages in Header and About and Skills) — pick one place.
- ❌ Dev-style CV on a non-tech profile ("Technical Skills: Python, Go"; cold metric-only bullets) — wrong register, kills the warmth that sells hospitality/sales/care candidates.
- ❌ Rebuilding only from
candidate_profile.yml without reading the original CV — produces cold, interchangeable output.
- ❌ More than 5 bullets per experience — recruiters skim, you lose the lead bullet's impact.
See also
application-flow — claim + path + UPSERT BEFORE you write a single line of CV.
critic-loop — the 3-round blind review that follows. Apply its Concrete Actions between rounds.
agents/_team/team-rules.md T10 (read-only profile) + T11 (deliverables in $JHT_USER_DIR).
agents/scrittore/scrittore.md — the orchestrator prompt that calls this skill in the main loop.