platform/resume-mgmt/SKILL.md

name: resume-mgmt description: >- Edit, rebuild, tailor, or publish James's resume — the HTML source in the portfolio repo, the generated PDF, and the structured resume.json the site and chat agent read. Also covers reverse-engineering a design back out of a browser-printed PDF when the source is lost. Use for "update my resume", "change this bullet", "tailor my resume for X", "rebuild the resume PDF", "put the new resume on the site", or any resume wording/design change.

resume-mgmt

Where the resume lives

ArtifactPath (in ~/dev/portfolio)Role
HTMLpublic/resume/jcvolpe-resume-2026.htmlSource of truth for the printed resume
PDFpublic/resume/jcvolpe-resume-2026.pdfGenerated by pnpm resume:pdf. Never hand-edit
JSONgenerated/resume.jsonStructured data the About page and the eve chat agent read
Profilegenerated/profile.jsonCurrent role/location + resumeFilename, the PDF the About page serves
Vault MDvault/**/*.mdBuilt from the JSON by pnpm vault:build; feeds chat semantic search

The HTML and the JSON are independent copies of the same facts. Changing one does not touch the other. A wording change the user asks for is usually a change to both — check whether the JSON still agrees before declaring done.

Workflow for a wording or content change

  1. Edit public/resume/jcvolpe-resume-2026.html.
  2. pnpm resume:pdf — Playwright renders it through the installed Chrome and asserts the result is still one page. A resume that spills to page 2 is a failure, not a new page.
  3. Mirror the same change into generated/resume.json if the fact is one the site/chat states (title, dates, bullets, skills).
  4. If the chat agent must know it: pnpm vault:build, then pnpm exec tsx --env-file=.env.production scripts/build-vault-vector-store.ts (the pnpm vault:vector-store:build script points at .env.local, which has no OpenAI key; the prod store id and key live in .env.production). It replaces only the vault files and leaves the project-doc entries alone. The vault markdown the deployed chat greps is bundled into the Lambda, so read_file/grep keep answering with the old text until the next deploy — only file_search updates immediately.
  5. Read the rendered page back (pdftoppm -r 130 -png <pdf> out, then view the PNG) before reporting. Line wrap and page fit only exist after rendering.

DynamoDB (PortfolioStack-AdminData, PK=PROFILE) outranks both JSON files once an admin save has happened — until then reads fall back to the committed seed. Check before assuming which layer is live:

aws dynamodb get-item --region us-east-1 --table-name PortfolioStack-AdminData \
  --key '{"PK":{"S":"PROFILE"},"SK":{"S":"MAIN"}}'

Publishing to the site

src/app/about/page.tsx serves the admin-uploaded profile.resumePdfKey when set, otherwise public/resume/<profile.resumeFilename>. Adding a new PDF to public/resume/ publishes nothing on its own — one of those two fields has to point at it (admin upload at /admin, or the profile field).

Rebuilding a design from a printed PDF

When the only copy of a resume is a PDF that some tool printed, the design is recoverable — and this is faster than eyeballing it. Precise numbers first, then one or two calibration passes.

pdftotext -layout in.pdf -       # copy, in reading order and columns
pdffonts in.pdf                  # the actual typefaces
pdftoppm -r 130 -png in.pdf page # full-page look
pdftoppm -r 300 -png -x X -y Y -W W -H H in.pdf crop   # zoom a detail

Exact colors and font sizes live in the content streams. qpdf, mutool, and PIL may or may not be installed; this stdlib snippet always works:

import re, zlib
from collections import Counter
data = open('in.pdf','rb').read()
out = []
for m in re.finditer(rb'stream\r?
', data):
    s = m.end(); e = data.find(b'endstream', s)
    try: out.append(zlib.decompress(data[s:e]))
    except Exception: pass
blob = b'
'.join(out)
hexes = ['#%02x%02x%02x' % tuple(round(float(v) * 255) for v in c)
         for c in re.findall(rb'([0-9.]+) ([0-9.]+) ([0-9.]+) rg', blob)]
print(Counter(hexes).most_common())
print(Counter(re.findall(rb'/(F\d+) ([0-9.]+) Tf', blob)).most_common())

The one that costs an hour: in a PDF printed by Chrome, the page content is drawn under a 0.75 scale, so the Tf sizes are the CSS px values, not points. Write 37px, not 37pt. Taking them as points renders everything 1.33x too big and pushes a one-page resume onto two.

Calibrate by comparing word widths rather than by eye — pdftotext -bbox on both the original and your rebuild, then diff the xMax - xMin of the same word. A ratio near 1.0 means the size is right; a ratio that differs per element means that element's size is wrong, not the global scale. Expect two or three render → compare → tune passes.

Gotchas

  • chromium.launch({ channel: 'chrome' }) uses the Chrome already installed on the machine. Plain chromium.launch() wants Playwright's own browser download, which is often absent even in a repo with Playwright tests.
  • page.pdf({ preferCSSPageSize: true }) is what honors @page { size: A4 }; without it the output silently becomes Letter.
  • Scripts in the portfolio repo transpile to CJS — no top-level await. Wrap the body in async function main() and call it, like the sibling scripts.
  • The portfolio repo forbids code comments; keep resume context in the repo's AGENTS.md or Linear, not in the HTML.