>_EXECUTIVE SUMMARY
zpwr-jobs is a private job-application management pipeline. One repo holds the resume, each company's posting, the tailored cover-letter prose, and the rendered deliverables. A single stryke script turns one structured block model into both a docx and a pdf styled to match the resume.
Rendering is native Rust inside stryke-office — there is no LibreOffice / soffice / pandoc / python anywhere in the chain.
The repo is a standard stryke project (stryke.toml + lib/ + t/). Pure text/parse logic lives in lib/CoverLetter.stk, unit-tested via s test with stryke alone (no stryke-office), so the suite runs in headless CI. Letters are hand-written or AI-drafted: draft_letter.stk calls stryke's ai builtin with a structured schema, and main.stk — the s run entry — scans jobs, drafts any missing letter, renders, and opens the result. The render path stays pure native Rust; the only network call is the optional draft.
~WORKFLOW & PIPELINE
Workflow: drop jobs/<JOB>/job_desc.txt, run s run. The main.stk entry scans every job — letter missing → AI-draft (draft_letter.stk) then render; pdf missing or older than the letter → render; else skip — then opens what it rendered (or the newest letter by mtime). [scripts] aliases expose the steps individually: s run draft, s run gen, s run apply, s test. AI drafting needs ANTHROPIC_API_KEY (the ai builtin's Anthropic provider); rendering and s test do not.
Renderer: stryke cover_letter_pipeline.stk <JOB> runs four stages over a single in-memory block model. Every config.toml setting is overridable per run via stryke's getopts builtin (short or long flags, before or after <JOB>; prefix the arg list with -- to place a flag first). An unknown formats token fails loud (no silent no-op).
| Stage | Detail |
|---|---|
| 1 · read inputs + settings | config.toml ([identity] letterhead, [generator] behavior, [letter] content params), resume.txt (facts + style), jobs/<JOB>/letter.txt (tailored prose). CLI short/long flags (stryke getopts) override config per run; config is parsed by stryke toml_decode. Missing job_desc.txt warns, does not stop. |
| 2 · parse + fill | Key: value headers split from blank-line-separated body paragraphs; every field then run through stdlib template() (mustache) against a shared map — headers (lowercased) + role + config identity + date (today, Month D, YYYY) — so fields can reference {{role}}, {{company}}, {{name}}, {{date}}. |
| 3 · build model | letterhead bar → contact → date → recipient → subject bar → salutation → body → closing, assembled once. Date defaults to today (via localtime) when the letter uses {{date}} or omits it; Salutation defaults to config.toml's when omitted. |
| 4 · emit | same model rendered to docx and pdf into jobs/<JOB>/<Company>_<username>.{docx,pdf}. |
@FORMAT → CALL
Both formats come from the same block model via two stryke-office calls.
| Concern | Implementation |
|---|---|
| docx body / runs | Office::doc_write — per-run serif throughout (justified body); font + sizes are config: body_font (default Cambria), body_size (body, default 11pt), head_size (letterhead/date/recipient/salutation/closing/name, default 11pt), ink text color (default black) |
| docx section bars | single-cell shaded table, bg => "#26ADE4" + white bold run (docx-rs exposes cell shading, not paragraph shading) |
| docx page | page_size => [12240, 15840] twips (US Letter) |
| pdf layout | Office::pdf_build — absolute top-left coordinates, manual y-cursor over rects + text |
| pdf section bars | cyan rect fill + overlaid white text at computed baseline |
| pdf body font | base-14 serif via config pdf_font (default times = Times-Roman, no font embedding); greedy word-wrap at ~size×0.5 char width |
$DESIGN NOTES & TRADEOFFS
| Decision | Why |
|---|---|
| bars as tables (docx) | doc_write applies shading to table cells, not paragraphs — a single-cell table is the only way to get the cyan bar with a styled run. |
| ASCII-fold for pdf | pdf_build uses base-14 fonts (Latin-1) and maps any char >127 to ?; bullets → | and em/en dashes → - are folded before emit. docx keeps the original UTF-8. |
escape @ in strings | stryke (Perl5 superset) interpolates @name as an array inside double quotes; the email needed \@ or the local-part was silently dropped. |
| builtin name clashes | ascii and trim are stryke builtins; the helper is CoverLetter::fold_ascii (in lib/) and the builtin trim is reused. |
pure logic in lib/ + t/ | fold, word-wrap, contact assembly, letter parse, paragraph split, word count, filename token, formats validation, and letter-text assembly live in lib/CoverLetter.stk so t/ can unit-test them with stryke alone (no stryke-office) — the suite runs in headless CI. Render output is byte-identical before/after the extraction. |
| AI draft kept upstream of render | drafting (the ai builtin — non-deterministic, network) lives in draft_letter.stk, separate from the renderer, so the renderer stays pure/deterministic/tested. ai(prompt, schema => +{…}) returns a typed hashref; the letter text is then assembled deterministically. |
ai prompt is arg 0 only | ai "x", $doc uses only the first arg as the prompt; a bare second-positional string is not sent, so file content is interpolated into the prompt string (ai "…#{$resume}…"). |
| one-shot redraft on overflow | s run / apply redraft shorter (up to 3×, with a ~300-word ceiling) when a draft trips the one-page guard, so the one-shot reliably yields a one-page letter despite LLM length variance. |
system() array scalar-context | system "cmd", …, @list evaluates the array as its count; main.stk passes a scalar $job per call. The --render boolean is OR'd across the two getopts passes so it survives the merge. |
head spacing scales with head_size | pdf meta-line advances derive from head_lh = int(head_size × line_height) (not fixed), so raising head_size no longer overlaps lines; line_height is the lever to fit a long letter without shrinking the font. |
| no soffice render | the no-external-binary rule means no Word-fidelity docx→pdf rasterization; the pdf is built independently from the same model, not converted from the docx. |
| config comments on own lines | stryke's toml_decode keeps a trailing # … as part of the value, so every config.toml comment sits on its own line — never after a value. |
getopts double-pass | getopts stops at the first positional, so it runs once before and once after <JOB> — flags work on either side. open_after is a negatable bool (!); the two passes are OR'd so a single -o survives. |
-- for leading flags | stryke reads a leading -x as one of its own switches; prefixing the script args with -- (stryke … .stk -- -f docx reuters) passes them straight to @ARGV. |
/SCOPE & LIMITS
One-page invariant: a cover letter must never exceed a single page. The renderer enforces it — after laying out the PDF it checks the final cursor against the printable height ($PH − margin) and aborts without writing if the content overflows, exceeds max_words, or falls under min_words. This guard is measured on the PDF layout and gates both formats (the docx tracks it as a proxy; Word reflows independently). Length is tuned via max_paragraphs / max_words / min_words / body_size / head_size / line_height / margin.
Tailoring — mapping a posting to the candidate's real accomplishments — is done per job in letter.txt, either by hand or AI-drafted by draft_letter.stk under explicit rules (real resume facts only, never fabricate employers/titles/dates/metrics; treat resume omissions as space constraints, not capability gaps). cover_letter_pipeline.stk itself stays a deterministic renderer, not a generator of claims. The docx uses the resume's serif font (config body_font, default Cambria) and full UTF-8; the pdf approximates with a base-14 serif (config pdf_font, default Times) and folded ASCII, the documented cost of shipping with zero font embedding and zero external binaries.
Scope of the "zero external binaries" claim: it describes the cover-letter path, which is the pipeline this report is about. The repo also carries a second, unrelated renderer for the resume and CV, and that one is not binary-free — see below.
%THE RESUME / CV RENDERER
The resume and CV deliberately do not go through stryke-office. They are authored as HTML and printed by headless Chrome over the Chrome DevTools Protocol, for one reason: Chrome's --print-to-pdf cannot render a page-number footer, so render-pdf.js drives Page.printToPDF itself to get displayHeaderFooter + footerTemplate. It is a single script covering every document rather than one script per variant.
<doc> | Source | Contents |
|---|---|---|
resume | resume.html | the base resume — single-column, ATS-clean |
resume-tech | resume-tech.html | the resume plus a per-role tech-stack line |
resume-ft | resume-fulltime.html | full-time roles only |
resume-nose | resume-noselfemployed.html | the base resume minus the self-employed role |
resume-nc | resume-nocounts.html | the base resume minus every product / language / book / invention count |
resume-nctech | resume-nocounts-tech.html | the no-counts resume plus a per-role tech-stack line |
resume-consult | resume-consultant.html | consulting / systems-architect angle — engagement model, modernization practice, client-named roles |
resume-ai | resume-ai.html | AI / LLM engineer angle — RAG and chatbot client delivery, the ai language primitive, MCP servers |
exec | exec-summary.html | one-page executive summary |
frontend | frontend-summary.html | one-page frontend summary — web-UI experience per role + the authored zgui-core component library |
cv | clients.html | selected projects & work |
| Decision | Why |
|---|---|
CDP instead of --print-to-pdf | the flag has no footer hook at all; Page.printToPDF takes an HTML footerTemplate with pageNumber / totalPages spans, which is the whole reason for the extra machinery. Chrome is spawned --headless=new on port 9411, driven over the page target's websocket, and killed when the render completes. |
| Ghostscript metadata pass | CDP writes no DOCINFO, so a second pass pipes a pdfmark block through gs to stamp Title / Author / Subject / Keywords. Metadata strings are ASCII-only with parentheses escaped, since they become PostScript string literals. If gs fails the un-stamped PDF is restored and a warning printed — the render is never lost to the metadata step. |
| Shared keyword surface | the resume ATS keyword list is defined once; the tech variant reads it directly and the full-time / no-self-employed / no-counts / no-counts-tech / executive-summary entries are assigned it after the table is built, so a keyword is never maintained in seven places. The CV and the frontend summary target different searches and carry their own lists. |
| ATS extraction assertion | a resume that renders beautifully but doesn't extract is worthless. Each resume document declares an atsCheck string and the script asserts it survives pdftotext, so a layout change can't silently break machine readability. The CV sets it to null and is skipped. |
| Fixed geometry in code | US Letter, 0.5″ side and top margins, 0.6″ bottom, printBackground on and preferCSSPageSize off — the script owns the page box so an edit to a stylesheet cannot silently change the paper size. Page count is reported from pdfinfo after each render. |
| macOS-only, by construction | the Chrome binary path is hardcoded to the macOS bundle and the script exits early when it is absent. Prerequisites are Chrome, Ghostscript and poppler; none are needed by the cover-letter pipeline, and none are checked by s test. |
The script's own usage: line names only <resume|cv>; the seven other document keys work but are not listed there.
$SOURCE MAP
| Path | Role |
|---|---|
| stryke.toml | stryke project manifest — [package], [bin], [scripts] (draft / apply / gen / test) |
| main.stk | s run entry — scan jobs, AI-draft missing letters, render stale/missing, open results |
| cover_letter_pipeline.stk | the renderer — getopts CLI + toml_decode config, parse inputs, build model, emit docx + pdf |
| draft_letter.stk | AI letter drafter — ai builtin + structured schema → letter.txt; --render chains the renderer, redrafts shorter on overflow |
| lib/CoverLetter.stk | pure text/parse helpers (fold, wrap, parse, split, word-count, token, formats validation, letter assembly) — unit-tested |
| t/ | unit tests for lib/ — run with s test (stryke only, no stryke-office, CI-safe) |
| render-pdf.js | the resume / CV renderer — headless Chrome over the DevTools Protocol; nine documents keyed by argv, page-number footer, Ghostscript metadata stamp, pdfinfo page count, pdftotext ATS check |
| render-resume.sh / render-cv.sh | thin bash wrappers over node render-pdf.js resume / cv |
| resume*.html, exec-summary.html, frontend-summary.html, clients.html | the HTML sources for those nine PDFs — base resume, tech-stack variant, full-time-only, no-self-employed, no-counts, no-counts + tech, the executive summary, the frontend summary, and the CV |
| config.toml | identity (username, name, contact, accent, ink, fonts) + generator behavior + letter content params; parsed by stryke toml_decode |
| resume.txt | candidate facts + the style every letter matches |
| jobs/<JOB>/job_desc.txt | raw posting (reference / tailoring source) |
| jobs/<JOB>/letter.txt | tailored letter — headers + body paragraphs (hand-written or AI-drafted) |
| docs/index.html | HUD documentation |
| docs/report.html | this report |
#PROJECT METADATA
| Item | Value |
|---|---|
| Visibility | private |
| Repository | github.com/MenkeTechnologies/zpwr-jobs |
| Runtime | strykelang |
| Renderer | stryke-office |
| Meta umbrella | MenkeTechnologiesMeta |