feat(setup): hooks.sh — installs post-commit hook
feat(setup): hooks.sh — installs post-commit hook.
feat(setup): hooks.sh — installs post-commit hook.
fix(setup): cron-agents.sh idempotent for morning-brief, clean log path.
Today's session started with one question: how do I make my dev logs actually useful after the fact? Daily standups are fine for "what did I do yesterday," but they're terrible for finding that one decision I made three weeks ago about why I chose lunr over algolia. The answer turned out to be a Docusaurus blog with full-text search — and a skill to write posts at the end of each session.
Before touching the blog, I added four new skills to cover gaps in the dev workflow: /dev-plan for thinking before coding (read-only, no edits allowed), /dev-test for writing and running tests, /dev-retro for sprint retrospectives, and /dev-refactor for behavior-preserving restructuring. Each one follows the same pattern — frontmatter, setup reads, structured process — so they feel consistent with the existing /dev, /dev-review, /dev-debug, and /dev-standup skills.
Docusaurus scaffolds fast with npx create-docusaurus@latest, but the defaults assume you want docs + blog. I stripped it down to blog-only mode by setting docs: false and routeBasePath: '/' so the blog is the homepage. The sidebar shows all posts, and there's a tags page for filtering.
The first build failed immediately — one of the older logs had raw JSON with curly braces, and MDX tried to parse them as JSX expressions. Setting markdown.format: 'md' globally fixed it. These are dev logs, not interactive docs — plain markdown is the right call.
For search, I went with docusaurus-lunr-search. It indexes at build time and runs entirely client-side, no external service needed. It picked up all 8 seeded posts on the first build.
I wrote tool/bin/sync-logs.js to bridge the existing log/YYYY/MM/DD.md standup entries into blog posts. It walks the log directory, injects frontmatter (title, date, author, auto-detected tags), strips the date header, and writes to site/blog/. It's idempotent — safe to re-run with npm run site:sync.
The auto-tagging is simple keyword matching: if the log mentions "debug" or "bug," it gets the debug tag. "Met with" or "sync" gets meeting. Not perfect, but good enough for seeding — hand-written posts will have better tags.
/dev-log to /dev-blogThe first version of the session logging skill was called /dev-log and used a rigid template: What changed, Decisions, Next. It produced accurate changelogs but they read like release notes. Renaming it to /dev-blog was the easy part — the real change was rewriting the format guidance to encourage actual storytelling. Hooks, subheadings that follow the content, code snippets where they help. This post is the first one written under the new format.
Earlier in the session (feels like a different day), I split .zshrc into sourced config files: aliases.zsh, functions.zsh, lazy.zsh, and path.zsh. The big win was isolating the nvm lazy-loading wrapper, which was tangled up with PATH setup. Also removed the eager nvm load that was adding ~500ms to shell startup.
The second half of the day was about making the shop repo work across two machines — personal and work (sbg). The .zshrc already had the plumbing for environment-specific configs: it reads ~/.shop-env to get a SHOP_ENV value, then sources config/zsh/env/$SHOP_ENV.zsh. But the actual env files didn't exist yet.
I created personal.zsh and sbg.zsh under config/zsh/env/. The old work.zsh from the tk migration was sitting there with all the sinclair project aliases, zscaler kill script, FFmpeg color bar streaming commands, and lazy loaders for GVM and Docker. I moved everything into sbg.zsh and swapped the hardcoded /Users/sjfox paths for $HOME so it works regardless of username.
The interesting part was the branch strategy. Both personal and sbg branches share everything — agent system, skills, blog site, zsh base config — but each branch only carries its own env file. A git checkout personal gives you a machine with personal.zsh; git checkout sbg gives you sbg.zsh. Clean separation without any conditional logic. The merge-then-diverge workflow was simple: merge personal into sbg to sync shared work, then make one commit on each branch removing the other's env file.
Both branches are now pushed to the GitLab remote, ready to be checked out on the right machine.
The evening session went somewhere different — SSH'd onto my Raspberry Pi and wrote a test suite for the home server backend. The raspi-home-server project is an Express + TypeScript app that manages heaters, thermometers, and thermostats via GPIO and ESP32 microcontrollers. It had a PM2-managed client and server, a nice component architecture, and exactly 16 tests — 1 failing, 3 skipped.
Claude Code can't handle interactive password prompts, so key-based auth was the only path. One ssh-copy-id sjfox@192.168.68.142 later, I had a working SSH tunnel and could run commands on the Pi from my local terminal. The whole session was done this way — writing test files locally, scp'ing them over, running Jest remotely.
The one failing test — "turns heater off when thermometer temp greater than thermostat max" — was a classic case of tests not keeping up with code. The thermostat logic had gained a +1 hysteresis buffer (thermometer.tempF > thermostat.max + 1) to reduce heater cycling, but the test still expected tempF: 67 to trigger an off at max: 66. Bumping to 68 fixed it.
The three skipped tests were more interesting. They tested early-return guards in compareZoneThermostatAndThermometer by asserting logger.debug was called. But the debug logging is gated behind a flag:
const debug = logging.debug.thermostat.compareZoneThermostatAndThermometer;
// ...
debug && logger.debug(errorMessage.missingThermostat);
The const debug captures the boolean at module load time. Setting the flag in beforeEach is too late — the value is already frozen. The fix was replacing the Winston mock with a direct mock of the logger service module, setting the flag to true in the mock factory so it's correct when actions.ts first imports it.
From there it was a sweep through every untested module, prioritized by risk:
Thermometer store (18 tests) was the biggest win. The setThermometer function has validation, a 60-entry rolling average cache for smoothing temperature swings, and side effects that feed into the thermostat decision loop. All untested before this.
Thermostat store (17 tests) covered the validation gauntlet — type checks on min/max, range validation, heater override status validation, and the merge-with-previous-state logic.
Zone store and actions (12 tests) filled the gap around onThermometerUpdate (the zone lookup path that was commented out in the original tests) and added boundary tests for the hysteresis logic — confirming that temp at exactly max + 1 doesn't trigger off, and temp at exactly min doesn't trigger on.
Middleware (7 tests) covered the route logger's method filtering and body serialization. Utility services (10 tests) hit password hashing round-trips, SHA-256 consistency, UUID format, and ISO date output. Heater controller (5 tests) tested the SSE vs JSON branching pattern. System store (4 tests) mocked /proc/cpuinfo and the thermal zone file, using jest.useFakeTimers() to tame the setInterval that polls Pi temperature every 5 seconds at module load.
Editing files remotely through SSH had one rough edge: escaping quotes through multiple shell layers. A Python script to fix two strings turned into a 15-minute detour when sed, heredocs, and nested escapes all mangled the content differently — at one point injecting \x01 and \x08 control characters into the test file. The fix that finally stuck: edit locally, scp the file over. Simple beats clever.
10 test suites, 89 tests, all green. The backend went from 75% untested to having coverage on every store, the thermostat decision engine, middleware, utilities, controllers, and the system monitor. The only things left untested are the remaining controllers (identical pattern to the heater one) and the Redis service (commented out in production).