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