// ZPWR-JOBS — ENGINEERING REPORT

stryke project · s run → AI-draft + render · main.stk / draft_letter.stk / cover_letter_pipeline.stk · one block model → docx + pdf · zero external binaries

>_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.

stryke
runtime
Office
render namespace
2
output formats
0
external binaries
1
block model
#26ADE4
accent
US Letter
page
docx·pdf
deliverables
ai
draft builtin
s test
CI unit tests

~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).

StageDetail
1 · read inputs + settingsconfig.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 + fillKey: 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 modelletterhead 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 · emitsame 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.

ConcernImplementation
docx body / runsOffice::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 barssingle-cell shaded table, bg => "#26ADE4" + white bold run (docx-rs exposes cell shading, not paragraph shading)
docx pagepage_size => [12240, 15840] twips (US Letter)
pdf layoutOffice::pdf_build — absolute top-left coordinates, manual y-cursor over rects + text
pdf section barscyan rect fill + overlaid white text at computed baseline
pdf body fontbase-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

DecisionWhy
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 pdfpdf_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 stringsstryke (Perl5 superset) interpolates @name as an array inside double quotes; the email needed \@ or the local-part was silently dropped.
builtin name clashesascii 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 renderdrafting (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 onlyai "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 overflows 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-contextsystem "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_sizepdf 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 renderthe 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 linesstryke'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-passgetopts 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 flagsstryke 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>SourceContents
resumeresume.htmlthe base resume — single-column, ATS-clean
resume-techresume-tech.htmlthe resume plus a per-role tech-stack line
resume-ftresume-fulltime.htmlfull-time roles only
resume-noseresume-noselfemployed.htmlthe base resume minus the self-employed role
resume-ncresume-nocounts.htmlthe base resume minus every product / language / book / invention count
resume-nctechresume-nocounts-tech.htmlthe no-counts resume plus a per-role tech-stack line
resume-consultresume-consultant.htmlconsulting / systems-architect angle — engagement model, modernization practice, client-named roles
resume-airesume-ai.htmlAI / LLM engineer angle — RAG and chatbot client delivery, the ai language primitive, MCP servers
execexec-summary.htmlone-page executive summary
frontendfrontend-summary.htmlone-page frontend summary — web-UI experience per role + the authored zgui-core component library
cvclients.htmlselected projects & work
DecisionWhy
CDP instead of --print-to-pdfthe 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 passCDP 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 surfacethe 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 assertiona 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 codeUS 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 constructionthe 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

PathRole
stryke.tomlstryke project manifest — [package], [bin], [scripts] (draft / apply / gen / test)
main.stks run entry — scan jobs, AI-draft missing letters, render stale/missing, open results
cover_letter_pipeline.stkthe renderer — getopts CLI + toml_decode config, parse inputs, build model, emit docx + pdf
draft_letter.stkAI letter drafter — ai builtin + structured schema → letter.txt; --render chains the renderer, redrafts shorter on overflow
lib/CoverLetter.stkpure 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.jsthe 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.shthin bash wrappers over node render-pdf.js resume / cv
resume*.html, exec-summary.html, frontend-summary.html, clients.htmlthe 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.tomlidentity (username, name, contact, accent, ink, fonts) + generator behavior + letter content params; parsed by stryke toml_decode
resume.txtcandidate facts + the style every letter matches
jobs/<JOB>/job_desc.txtraw posting (reference / tailoring source)
jobs/<JOB>/letter.txttailored letter — headers + body paragraphs (hand-written or AI-drafted)
docs/index.htmlHUD documentation
docs/report.htmlthis report

#PROJECT METADATA

ItemValue
Visibilityprivate
Repositorygithub.com/MenkeTechnologies/zpwr-jobs
Runtimestrykelang
Rendererstryke-office
Meta umbrellaMenkeTechnologiesMeta