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


$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)
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