Astro blog, pnpm. pnpm dev (drafts visible), pnpm build (drafts excluded; prebuild runs scripts/check-main-tags.mjs and scripts/check-banned-phrases.mjs, both fail the build on violations in published posts).
Stefan is the creator and makes the choices. On decisions with real alternatives (structure, naming, what a post says), propose with reasoning and let him pick before implementing. His questions ("or?", "should we...?") open a discussion; they are not approval.
Posts live in src/content/writing/ as .md, or .mdx when using components (e.g. Term, the hover-translation tooltip in src/components/Term.astro). Frontmatter: description required, at least one main tag (tech or life) required for the RSS topic feeds, draft: true keeps a post out of production, related: [slug, ...] overrides the tag-based related-posts matching, image overrides the default OG image.
For a topic with both a life and a tech dimension, prefer two companion posts over one dual-audience post. Life post keeps its reflective register; tech post can go into setup, code, dir structure, decision archaeology. Cross-link them via related: [slug] in both frontmatters, and inline at natural moments with varied anchor text ("the engineering companion" / "the reflection that made me build this"). Same anchor twice dilutes signals.
Trigger: at draft-start, when the tech content would take more than one skippable section to cover. Also valid: writing a companion to an existing post when the other side has grown substantial enough to stand alone.
Rules for both posts:
- Distinct titles, slugs, and descriptions. No verbatim overlap in metadata.
- Different primary tags (
lifevstech) plus topic tags per dimension. - No copy-pasted paragraphs. Ideas can echo, sentences don't.
- Each targets its own keywords in title, first paragraph, and headings.
Why: topic-cluster architecture drives more organic traffic and AI citations than dual-topic single posts when search intents are genuinely different (different queries, different reader profiles). Cleaner writing too: each post keeps its own voice register instead of forcing unity across dimensions.
Two layers, both auto-loaded from .claude/rules/:
writing-style.md— principles. Numbered rules; rule 5's banned phrases are build-enforced fromscripts/banned-phrases.txt(the canonical list, outside.claude/so the Docker build can run the check).voice-corrections.md— evidence. Before/after pairs and vetoed words from editing sessions. Judgment-level, not build-enforced.
Capture discipline, every writing session:
- Hard veto ("never use X", "that's an AI tell") → record it in
voice-corrections.mdin the same turn, with Stefan's stated why. Rejected lines never touch disk; a later harvest cannot recover them. - Softer rewrite-pairs → harvest in the pre-publish sweep (below).
- New feedback contradicting an existing entry → update or delete the old one. Newer supersedes.
- A veto that keeps recurring across posts graduates to a numbered rule (or, if mechanically matchable, to the banned-phrases block) and leaves voice-corrections.
- Draft with
draft: true. Anchor in Stefan's published posts andvoice-corrections.md, not generic blog voice. If the topic has both life and tech dimensions, decide the companion split first (see## companion posts). - Review via
/review-prose(writing-coach agent). Stefan picks which findings to apply. - Commit the draft early; iterate in further commits.
- Pre-publish sweep: harvest new voice pairs from this draft's editing history into
voice-corrections.md, prune contradictions, setpubDateto the actual publish date. If the draft changed substantially since its last review, run/review-prose <file> deltaon the final text — post-review edits are where new damage hides. - Flip
draft: false,pnpm build, commit, push (deploy runs via GitHub Actions → Watchtower). - Linear: mark the post's ticket Done with the published URL attached (team
stefanahman, projectstefanahman.com). Create the ticket if none exists; file spin-off post ideas as Backlog tickets so they aren't lost.
The newsletter skill turns a published post into a Buttondown draft.