<feed xmlns='http://www.w3.org/2005/Atom'>
<title>rulesets/claude-templates/.ai/scripts/tests, branch main</title>
<subtitle>Claude Code skills, rules, and language bundles
</subtitle>
<id>https://git.cjennings.net/rulesets/atom?h=main</id>
<link rel='self' href='https://git.cjennings.net/rulesets/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/'/>
<updated>2026-07-31T18:36:21+00:00</updated>
<entry>
<title>fix(sync): narrow the dirty-rulesets guard to per-file excludes</title>
<updated>2026-07-31T18:36:21+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-31T18:36:21+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=f69dc22c80156f92c6e3519e87bc014558d77dbf'/>
<id>urn:sha1:f69dc22c80156f92c6e3519e87bc014558d77dbf</id>
<content type='text'>
One uncommitted file under the synced template paths used to skip all three rsyncs for every project. On 2026-07-30 that froze every template everywhere for a full day, and five workflow files went stale in one project alone with nothing reporting it. The blast radius had no relation to what I was editing.

Each dirty path now becomes an --exclude on its own rsync. rsync honors an exclude on both sides, so a withheld file is neither overwritten nor deleted downstream. Every clean file still propagates. A dirty protocols.org skips only its own single-file transfer. A rename withholds both names, since sweeping the old copy would delete a file the project still runs mid-rename.

The run now names what it held back, so a project can see it's one file behind.

The behind-upstream guard stays all-or-nothing. That staleness lives in the destination, so there's no single source file to narrow to.

I verified it against the real checkout mid-change: 48 workflows propagated while the two files dirty at the time were withheld and named. The same run under the old guard would have synced nothing.
</content>
</entry>
<entry>
<title>refactor(startup): extract the template sync into a tested script</title>
<updated>2026-07-31T11:07:34+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-31T11:07:34+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=f57105773366aebdceeafdb51dc3584c42b0e4e4'/>
<id>urn:sha1:f57105773366aebdceeafdb51dc3584c42b0e4e4</id>
<content type='text'>
The .ai/ sync is how every workflow, protocol and script change reaches every project, and until now it was untested inline bash in startup.org. Yesterday it failed in two ways at once and nothing anywhere reported either.

An uncommitted edit of mine under the synced paths skipped all three rsyncs for a full day, so no project got anything. Five workflow files went stale in .emacs.d alone. One of them was a telegram plugin whose staleness crashed telega-server on every triage run, which is the only reason anyone noticed.

Work found the second failure. The --delete rsync silently reverts a local patch to a rulesets-owned file, so they hit a bug, fixed it, restarted, and came back running the broken version. Their own log still said it was patched.

I want both guards changed. Neither change is safe to make against inline bash that runs in every session, so this commit only moves the logic and pins what it does.

sync-templates is a faithful extraction, rough edges included. I verified it differentially rather than by reading: the old block and the new script over the same fixtures produce identical stdout, exit status and resulting tree.

Two of the fifteen characterization tests pin the failures above. "ONE dirty file blocks ALL THREE rsyncs" is the blast radius in one assertion, and the narrowing lands by turning it red on purpose. "a locally-edited template is silently overwritten, with no record kept" pins work's regression, asserting the output is indistinguishable from an ordinary sync and that no warning exists anywhere.

A third test pins a false-success path I found but deliberately did not fix: the success line prints unconditionally, so a run whose rsyncs all failed still reports a clean sync. That matters next rather than now, because a last-synced manifest written from that branch would stamp success onto a sync that did nothing. The script carries the warning at the line itself.

startup.org keeps a fallback for the case where it holds this file but the script is missing. It announces the skip and names the recovery command, because that state cannot heal itself: the fallback runs instead of the sync, so no later sync can deliver what is missing.
</content>
</entry>
<entry>
<title>refactor(sentry): call one loop cycle a cycle, not a fire</title>
<updated>2026-07-29T00:53:45+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-29T00:53:45+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=f3f5bfd651962934b230785d5a3449b738ce44ed'/>
<id>urn:sha1:f3f5bfd651962934b230785d5a3449b738ce44ed</id>
<content type='text'>
I read "nine fires" as nine emergencies and went looking for what was burning. The word reaches me directly, since digest headings render as Fire 11 in the anchor I read every morning.

72 noun-sense instances in sentry.org become "cycle". The four verb-sense uses stay, because firing is the right word for a trigger.

home proposed "pass" and I turned it down. sentry.org already uses that as a numbered noun for the eleven hygiene passes, so "Pass 11" would have collided with a referent in the same file. home confirmed it by walking into exactly that: its anchor ended up carrying "Pass 11" three lines from "pass 12". "Cycle" appears nowhere in the file, and it is the word I reached for myself when I flagged the problem.

Three more instances lived outside sentry.org: wrap-it-up.org, todo-cleanup.el, and its test. All three named a sentry cycle, so they move too and the vocabulary stays consistent across files.
</content>
</entry>
<entry>
<title>fix(lint-org): stop flagging correctly paired verbatim blocks</title>
<updated>2026-07-25T06:50:23+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-25T06:50:23+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=8822b0dab56ca50269771b4c9d06b78657c2b8b4'/>
<id>urn:sha1:8822b0dab56ca50269771b4c9d06b78657c2b8b4</id>
<content type='text'>
org-lint's invalid-block checker flags both delimiters of a paired example or src block whose body holds a heading-shaped line. Its parser reads the `** ` as a structural break and loses the open block. Every org file that documents org syntax inside a block collects two false findings.

The checker is org-lint's own, not one lint-org implements, so I filtered its output instead of patching a local checker. `lo--matched-block-regions` pairs blocks by scanning lines under org's real rule (once open, only the block's own `#+end_TYPE` closes it). A finding inside a matched region gets dropped, delimiters included, since that's where org-lint reports. I scanned lines rather than asking org because org's parser is what's confused.

An unterminated block never lands in a matched region, so it still reports. Three tests cover the false positives: a heading in an example block, a literal `#+end_example` inside a src block, and uppercase delimiters. Two more guard the real finding, including a file with one paired and one unterminated block that proves the suppression is per-block.
</content>
</entry>
<entry>
<title>fix: harden the org-file mutators and close four verified bugs</title>
<updated>2026-07-24T15:56:50+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-24T15:56:50+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=267d1de7b8a8e7fd22b156433567c88216ec3d0f'/>
<id>urn:sha1:267d1de7b8a8e7fd22b156433567c88216ec3d0f</id>
<content type='text'>
An overnight hygiene run over this repo, reviewed adversarially afterward. Every problem below was reproduced before it was fixed, and every fix was re-checked by a skeptic who was told to refute it.

cj-remove-block could silently destroy content. Its range check validated only the first and last lines, so a span from one cj block's opener to a later block's closer passed and the removal deleted everything between — prose, headings, whole tasks, with a zero exit. That is the failure the check exists to prevent, and drift is its normal case, since the calling skill edits the file while processing. It now refuses a range holding more than one block, backs up first, and writes atomically while preserving the file's mode and following symlinks to the real target.

todo-cleanup rewrote todo.org with no backup, alone among the three tools that mutate these files. It now copies to the temp dir like lint-org does. Both backup helpers suffix rather than overwrite, because a second-resolution stamp collapsed two back-to-back invocations into one backup holding already-mutated content — and running todo-cleanup twice in a row is the shipped path.

audit.bats failed about one run in eight, in teardown rather than in its assertions, so a passing test reported as a false red. git commit spawns a detached maintenance process that is still writing packs when the commit returns, racing the cleanup. The fixture disables it.

route_recommend downgraded a correct strong match to weak when two projects shared a basename. lint.sh now sweeps the scripts that get symlinked onto PATH, which were the only shell in the repo with no gate over them.

Two test defects found in review and fixed here: a suite that deleted real backups from the shared temp dir, and one that depended on that directory's contents. Isolation now lives in an autouse fixture rather than in each test's memory.

Known and filed, not closed: the range check still cannot prove the range is the block that was scanned, so drift by exactly one whole block deletes the wrong annotation. That needs a content assertion and a CLI contract change.
</content>
</entry>
<entry>
<title>fix(lint-org): scope the todo-format checkers out of docs/specs/</title>
<updated>2026-07-24T05:02:13+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-24T05:02:13+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=c38bab9f0005a7da82487674d3d8bceca36368a9'/>
<id>urn:sha1:c38bab9f0005a7da82487674d3d8bceca36368a9</id>
<content type='text'>
The five todo-format-family checkers encode todo.org completion conventions, but they ran on every org file, so they misfired across docs/specs/. That was 100 findings over nine specs in this repo, and the same noise in every project carrying specs. A spec's Decisions section legitimately uses a level-2 DONE with no CLOSED cookie. Its review-history uses level-2 dated headings. Neither is a completion defect there.

A path guard now skips the family on any file under a docs/specs/ segment, the canonical spec home per the docs-lifecycle rule. Link, table, and structural checks still run on specs, so a broken link in one still flags. todo.org and every non-spec org file keep the full checker set.

Folds in task-missing-last-reviewed alongside the four named checkers, since it's the same family and misfires the same way on a spec's phase tasks. Correcting an earlier note in the task: these are lint-org.el's own checkers, not org-lint's, so the fix is a local guard rather than filtering upstream output.
</content>
</entry>
<entry>
<title>fix(inbox-send): clean error on an unreadable source, dedupe overlapping roots</title>
<updated>2026-07-24T04:57:58+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-24T04:57:58+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=a053e9d1ecbf583b4a6f054c34f64ad1f359365e'/>
<id>urn:sha1:a053e9d1ecbf583b4a6f054c34f64ad1f359365e</id>
<content type='text'>
main caught ValueError and FileNotFoundError around the send, so an unreadable source raised a PermissionError as a raw traceback instead of the clean "inbox-send: &lt;message&gt;" every other failure produces. Widening the catch to OSError covers the unreadable source, the missing source, and any atomic-write failure alike.

discover_projects appended without deduping, so a roots config naming both a parent directory and one of its children listed the same project at two indices. It now dedupes on the resolved path, first-seen order preserved.
</content>
</entry>
<entry>
<title>fix(inbox-send): write handoffs atomically so a failure leaves no phantom</title>
<updated>2026-07-24T04:55:15+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-24T04:55:15+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=0f91a8e0cd0a4b5b351efe407987bb8bb6ab125e'/>
<id>urn:sha1:0f91a8e0cd0a4b5b351efe407987bb8bb6ab125e</id>
<content type='text'>
inbox-send wrote straight to the destination path, and write_text truncates on open, so any mid-write failure left a zero-byte .org in another project's inbox. inbox-status counted that phantom as a pending handoff and blocked a turn in the receiving project over a file with no content and no sender.

Both send paths now write to a temp sibling and os.replace it into place, so the inbox only ever sees a complete file. A caught failure removes the temp and re-raises, leaving no debris. encoding is pinned to utf-8 on the write and on the roots-config read, which closes the locale-dependent failure that first surfaced this.

inbox-status also skips the .inbox-send-* temp, so the brief window before the rename can't read as pending either. The atomicity is only complete once the consumer ignores the in-flight file.
</content>
</entry>
<entry>
<title>feat(languages): ship the missing python and typescript hooks</title>
<updated>2026-07-24T01:48:44+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-24T01:48:44+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=c238afbd4150ef53737f16e7dd84a565d2838ecc'/>
<id>urn:sha1:c238afbd4150ef53737f16e7dd84a565d2838ecc</id>
<content type='text'>
The python and typescript bundles carried rules and a coverage script but no pre-commit hook, so any project installing one got no credential scan on commit. Both now ship all four components the README documents: the shared secret scan, a validate-on-edit hook, settings wiring, and a seed CLAUDE.md. Verified against a real repo: a commit carrying an AWS key is refused.

install-lang now warns when a bundle is missing a documented component. That's the half that keeps this from recurring. Whoever adds the sixth bundle will forget something too, and today the installer prints success either way.

Two things fell out of the build. node --check is unusable on TypeScript: it ignores --experimental-strip-types, so it rejects valid TS and accepts broken TS. The hook uses tsc filtered to syntactic diagnostics instead. And completing the bundles means every pair now collides on settings.json and pre-commit, so no two compose without FORCE=1. The earlier "bundles already compose" reading rested on these two being incomplete. Filed for a real decision; clock-panel is the project that wants it.

Also stamps :LAST_REVIEWED: at task creation, with a lint checker to catch misses. Writing a task is reviewing it, so leaving the stamp off pushed every fresh task to the top of the next review batch to be re-derived by someone with less context than its author had. Tonight's sweep sent the staleness count from 13 to 22 while the list got more accurate.
</content>
</entry>
<entry>
<title>feat(flashcard): support multi-tag drill headings + --tag-filter / --guid-salt</title>
<updated>2026-07-19T23:40:22+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-19T23:40:22+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=a14e43b566946ba45abf58f75eb0a445b5b67ef3'/>
<id>urn:sha1:a14e43b566946ba45abf58f75eb0a445b5b67ef3</id>
<content type='text'>
Craig wanted a curated "DeepSat Fundamentals" deck: the ~100 most fundamental cards out of the 465-card deepsat.org deck, marked with a second org tag (:fundamental:) so they stay grep-able in the source. Both flashcard-to-anki.py and flashcard-stats.py keyed a card on a heading ending in exactly :drill:, so adding any second tag turned the heading into :fundamental:drill:, which that anchor didn't match. The 100 tagged cards would have silently dropped from the full-deck apkg and been undercounted by stats. That's the passing-gate-skips-your-file failure.

CARD_RE now matches a trailing org tag block, and a heading is a card when drill is among its tags. The other tags ride along as Anki tags next to the section tag. Card bodies are bounded by any L1/L2 heading (HEADING_RE) rather than only a section or a drill heading. flashcard-to-anki.py gains --tag-filter &lt;tag&gt; (emit only cards carrying that org tag, the subset deck) and --guid-salt &lt;s&gt; (a distinct GUID space so the subset imports non-empty instead of colliding with the full deck on GUID and importing empty). No salt is unchanged, so the existing deck's GUIDs and SRS state are untouched. flashcard-stats.py gets the same CARD_RE/HEADING_RE broadening plus a drill-membership guard.

This is the rulesets-canonical reconcile of a stopgap the work project ran locally. I re-derived it against the current canonical rather than copying the preserved files, which predate the #+TITLE deck-name fix (060a938) and would have reverted it.

parse()'s third element is now the Anki-tag list rather than a single tag string. Only build() and the tests consumed it. The existing parse tests move to the list contract, and new tests cover multi-tag parsing, --tag-filter, the guid salt, and the stats count. A bats case guards that a :fundamental:drill: card still counts. The full deck's real 465/100 check needs the work deck and isn't runnable here. Verified end to end instead on a synthetic multi-tag deck (2 cards, --tag-filter fundamental returns 1) through the real genanki path.
</content>
</entry>
</feed>
