CLAUDE.md

This file (GEMINI.md) is the single source of truth for this repo’s rules. CLAUDE.md and AGENTS.md are relative symlinks pointing here. Always edit GEMINI.mdagy (Antigravity) does not follow symlinks and only reads a real file, and Claude Code’s Edit tool refuses to write through a symlink. Measured 2026-08-10.

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Commands

# Serve locally with live reload (http://localhost:4000)
./serve.sh

# Build only, output to _site/
./serve.sh --build

# Verification gate — content lint + build, via the target the Hermes engine looks for
make test

# Content lint alone (instant, no container)
bin/check-content.py

# Generate a post cover image (see Images below)
bin/gen-post-image.py --slug <slug> --prompt "<subject>" [--n 3]

# Import numbered screenshots for the body of a post (see Screenshots below)
bin/add-post-images.py --slug <set> --from-cache <n>   # or 1=<file> 2=<file> ...
bin/add-post-images.py --slug <set> --apply _posts/<post>.md

make test is the project’s verification gate, and it runs two independent checks:

It exists because the Hermes engine (planexec.py) looks for a test: Makefile target to decide whether a project has a gate at all; without it run_tests returns None and the review approves on the reviewing model’s word alone — on a repo that publishes straight to production. Run it before any push.

serve.sh runs Jekyll inside a ruby:3.3 podman container, because the VPS has no Ruby and no root access to install one. Gems are installed into vendor/bundle on first run (gitignored); later runs reuse them. On a machine that does have Ruby installed, bundle exec jekyll serve works directly.

The server binds to 127.0.0.1 only. To view it from another machine, open an SSH tunnel from that machine:

ssh -L 4000:localhost:4000 -L 35729:localhost:35729 diego@<ip-da-vps>

then browse to http://localhost:4000. Port 35729 carries live reload.

_config.yml has an exclude: list keeping repo working files (CLAUDE.md, Skills/, bin/, serve.sh, Makefile, the Gemfiles) out of the published site — anything added at the repo root that is not site content must be added there too.

Deployment is fully automatic: pushing to the gh-pages branch triggers the GitHub Actions workflow (.github/workflows/deploy-pages.yml), which builds and deploys to GitHub Pages at dcamargo.com.br. A push is a publication — there is no staging step between gh-pages and the live professional site. Build clean (make test) before pushing.

Two ways this repo gets worked on

Besides interactive Claude Code, this project is also driven from Discord through the Hermes agent (channel #d-camargo-github-io), which runs a plan → run → review → push pipeline via ~/.hermes/skills/planexec/scripts/planexec.py. Two consequences for anything written here:

Architecture

Jekyll 4.4 static site. No collections — content is either _posts/ (Markdown) or plain HTML pages with YAML frontmatter.

Layouts (_layouts/):

Bilingual structure: Every page exists in two versions.

Blog listings filter posts by language: where_exp: "item", "item.lang != 'en'" for PT, the inverse for EN.

Post frontmatter:

---
layout: post
title: "Post Title"
lang: pt                    # 'pt' or 'en'
category: "Geoprocessamento"  # singular field, one category per post
permalink: /en/YYYY/MM/DD/slug.html   # required for English posts only
translation: /en/YYYY/MM/DD/slug.html # URL of the counterpart post in the other language; the nav language toggle uses it. On EN posts this is the PT post's date-based URL, e.g. "/2026/06/24/slug.html" (quote it) — no category in the path, see the note below. Omit if there is no counterpart — the toggle then falls back to the other language's blog index.
math: true                  # opcional; carrega MathJax naquela pagina. Sintaxe kramdown: $$...$$ (inline e display), nunca $...$
---

Categories: Each post declares a single category (singular field — the blog listings read post.category; do not use a plural categories list). The blog index (blog/index.html and en/blog/index.html) builds an interactive filter bar from the distinct categories and shows a gold badge on each card. Posts with no category fall back to Geral. Categories must be language-matched to the post — use the PT name on PT posts and the EN name on EN posts. Existing pairs:

PT EN
Engenharia de Transportes Transport Engineering
Geoprocessamento Geoprocessing
Planejamento Urbano Urban Planning
Geral General

Note: category no longer appears in post URLs. _config.yml sets permalink: /:year/:month/:day/:title.html, so PT posts resolve to /2026/06/24/slug.html (EN posts keep their explicit /en/... permalink). This replaced the Jekyll default /:categories/..., which put the category in the path — a single-word category like geoprocessamento was then being rewritten into a subdomain (geoprocessamento.dcamargo.com.br) by the domain’s URL forwarding, 404ing the page. Links are generated via post.url, so listings and nav follow automatically; only hardcoded cross-links between posts and EN translation: fields must use the date-based path.

Portuguese posts are named YYYY-MM-DD-slug.md; English counterparts use the same date and a matching slug with -en suffix, e.g. 2026-06-03-desire-lines-aon-delaunay-qgis-en.md.

Design system (all in assets/css/style.css):

Portfolio pages (portfolio/ and en/portfolio/) are plain HTML files using layout: default. Each page is self-contained with its own <style> block; there is no shared portfolio template.

Images: Post images go in assets/images/posts/; portfolio images go in assets/images/portfolio/.

Post cover images are generated at authoring time by bin/gen-post-image.py and committed as static assets — the Jekyll build never calls the API, so builds stay free, deterministic and secret-free. The script calls the Gemini image API (gemini-3.1-flash-image, “Nano Banana 2”, by default), applies a fixed house-style prompt derived from the design system, and writes a resized WebP capped at 200KB. Generated images carry an invisible SynthID watermark.

The API key lives in ~/.config/dcamargo/gemini.env (mode 600, outside the repo); GEMINI_API_KEY in the environment overrides it. Image models have no free tier — the key’s Google Cloud project must have billing enabled, otherwise every call returns HTTP 429 with free_tier_requests, limit: 0. A Google AI Pro subscription does not by itself grant API access (its benefits apply to the AI Studio web interface), but it does entitle the account to $10/month in Google Cloud credits via the Google Developer Program, activated manually at google.dev, which the API usage draws from.

Without billing there is a manual route that stays within the subscription: --print-prompt emits the full house-style prompt to paste into the Gemini app, AI Studio or Antigravity, and --from-file <path> imports the downloaded image through the same WebP normalisation. Do not attempt to reuse the Antigravity OAuth token in ~/.gemini/ as an API credential — it is not one.

Posts should carry an image: frontmatter field pointing at the cover; jekyll-seo-tag turns it into og:image. A PT post and its EN counterpart share one image file.

Screenshots in the body (posts about the QGIS plugins, mostly) are a separate thing from the cover and follow their own convention, enforced by bin/check-content.py:

The workflow has two steps. First import. Attachments are cached in the order they were attached to the Discord message, so when that is the intended order all it takes is the count:

bin/add-post-images.py --slug sigbus02 --from-cache 3

The script prints which file became which number, with each file’s age, so the mapping can be checked before anything else happens. Add --start 4 to append to a set that already exists.

The Hermes image cache is a single global directory — one pile for every channel and every topic. Working inside a per-post Discord topic does not isolate it. Since 2026-08-03 the Hermes side stamps the origin into the filename (img_<channel>_<topic>_<uuid>.png, patch C32 in the brain repo), so the right call inside a post’s topic is to filter by it:

bin/add-post-images.py --slug sigbus02 --from-cache 3 --marca "<topic name>"

Without --marca, --from-cache takes the most recent files from the whole pile, and an image sent minutes earlier in another channel lands in the post silently — the wrong image under the right number. The script also flags any file more than 15 minutes apart from the previous one, since attachments of one message are written seconds apart, and it always prints which file became which number: read that mapping before moving on. Attachments cached before 2026-08-03 carry no stamp; those need --from-cache without --marca, or the explicit N=<file> form.

When the order differs, give each number explicitly instead:

bin/add-post-images.py --slug sigbus02 \
    1=~/.hermes/cache/images/img_a1b2.png \
    2=~/.hermes/cache/images/img_c3d4.png

The script resizes to 1600px wide and writes WebP, trying lossless first and falling back to a quality ladder only when the file would exceed 300KB — screenshots carry interface text, where compression artefacts show up early. It refuses to overwrite an existing number without --force.

Second, in the draft, each image is marked at the exact point in the text where it belongs:

[[print 2: alt text describing the screen]]

and --apply swaps every marker for the real markdown link, once per language:

bin/add-post-images.py --slug sigbus02 --apply _posts/2026-08-05-post.md
bin/add-post-images.py --slug sigbus02 --apply _posts/2026-08-05-post-en.md

Markers out of sequence (2, 1, a repeat, a gap) abort the run and write nothing. Images already resolved by an earlier --apply count in that sequence, so a second batch added with --start is marked [[print 4: ...]] onwards, not renumbered from 1. An unresolved [[print N: ...]] marker left in a post is a gate error, so a marker can never reach production as raw text. A numbered file no post references is a gate warning.

Skills

Project skills are located exclusively in .claude/skills/<nome>/SKILL.md: