<feed xmlns='http://www.w3.org/2005/Atom'>
<title>rulesets/.ai/workflows, 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-29T02:28:29+00:00</updated>
<entry>
<title>feat(publish): isolate the pre-commit review and loop it to approval</title>
<updated>2026-07-29T02:28:29+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-29T02:28:29+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=80624606b8a60d97d8c7600882ddc35f5225469f'/>
<id>urn:sha1:80624606b8a60d97d8c7600882ddc35f5225469f</id>
<content type='text'>
The review already ran before every commit. It ran in the context that wrote the diff, which is the part that didn't work. A self-review checks the change against the author's own model of it and can't check the model, so the errors that survive are the ones that were never in the diff: a scope inherited from whoever reported the problem, a blast radius estimated instead of measured.

So the reviewer is now a subagent, on every commit including a one-line one. It gets the diff, a one-line claim of what the change does, and the ticket where one exists. It doesn't get the conversation or my reasoning, because those are the model it exists not to hold. The ticket stays in for the opposite reason: it was written before the work and is the only thing that can contradict my claim about my own diff.

Findings loop back to the same reviewer until it approves, rather than to a fresh one that can't tell an addressed finding from one that never existed. Three rounds, or a finding that recurs after being reported fixed, and it stops for a human instead. The unattended callers park the task rather than wait for an answer nobody is awake to give.

I gave the stance a substantiation floor. An agent told to attack will manufacture findings to satisfy the instruction, and a manufactured finding costs a round and teaches the author to discount the next review.

subagents.md said three separate times not to dispatch work this small, so it now carries an Isolation Override: the size heuristics assume the main thread could do the job equally well, and they lapse when its own context is what makes its answer untrustworthy. Field 2 of the Prompt Contract inverts there. Paste your context in and you hand over the very model you spawned the agent to escape.

I wrote it by running it on itself: three rounds and thirteen findings. Two were things I'd have shipped. Withholding the ticket made my own claim self-certifying, and the override reaffirmed the contract field that would have destroyed the isolation.
</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(triage): report a dead server after loadChats, not a quiet account</title>
<updated>2026-07-28T23:50:08+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-28T23:50:08+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=43a4cf73959e4f78bacbe5b3b40dde479377f9ff'/>
<id>urn:sha1:43a4cf73959e4f78bacbe5b3b40dde479377f9ff</id>
<content type='text'>
The load call returned 'loaded whatever happened. ignore-errors catches nothing there, because a bad argument kills the server process rather than signalling in elisp. A death during the load was invisible. The tail now returns 'server-died when the server is gone, and the recipe treats that as SCAN FAILED.

A dead server leaves a thin chat hash, and a thin hash reads exactly like an account with little unread. That's the false all-clear the down/not-loaded rule already guards against, arriving one step later in the lifecycle. It's also the independent evidence the gotcha asks for when it says to treat a short chat list as a real short list. Without it the loadChats crash stayed invisible through two investigations.

I used telega-server-live-p, the wrapper for the process-live-p expression the gotcha names. The fboundp guard comes from Step 0, which uses the same pair. A launch that failed outright leaves telega unloaded, and that should read as 'server-died rather than signalling void-function.
</content>
</entry>
<entry>
<title>fix(triage): pass the TL object to loadChats, not a bare symbol</title>
<updated>2026-07-28T19:07:05+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-28T19:07:05+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=bff01385a17fd8435f202d70362db13a139774d6'/>
<id>urn:sha1:bff01385a17fd8435f202d70362db13a139774d6</id>
<content type='text'>
Step 1 called (telega--loadChats 'main). That's a raw TL wrapper, so it drops its argument into the request as :chat_list untouched. The symbol went out on the wire as main, and the C parser aborts on any value it can't start. Every telegram sweep has been killing telega-server. Both call sites now pass '(:@type "chatListMain"), which is what telega's own callers use.

I merged this with the down-is-launch fix parked since 2026-07-24 rather than applying either alone. The parked file still carried the bad call, and its new prose cited the segfault gotcha as the reason docker mode is mandatory. That's the same gotcha this rewrites to say the deaths were our own bad argument. Shipping them in sequence would have left the file arguing against itself, so I reconciled the prose in both places and kept the docker requirement on its own evidence.

The gotcha keeps two caveats. A crash with no triage verb running needs its own investigation. And a short chat list is a real short list, because 19 was the true account size measured at both ends, not a scan truncated by this bug.
</content>
</entry>
<entry>
<title>refactor(rules): split testing.md, fix the approval gate, require first person</title>
<updated>2026-07-27T19:13:32+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-27T19:13:32+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=2f45b6e0010ce375e2c52c91a6537c6d6e8bc0a5'/>
<id>urn:sha1:2f45b6e0010ce375e2c52c91a6537c6d6e8bc0a5</id>
<content type='text'>
Three changes to the same layer.

I split testing.md the way I split commits.md, by what has to be resident rather than by size. What stays is the standing directive: TDD is the default, write the failing test first, and every unit needs Normal, Boundary, and Error cases. That has to fire before any code is written, which is exactly when no skill has been summoned, so it can't ride a trigger. Everything else moved to the testing-standards skill: characterization recipes, the per-category detail, property-based and mutation testing, the pyramid, integration rules, naming, the test-quality and mocking rules, coverage targets, the spike exception, and the anti-patterns. 2,824 words down to 347.

I fixed the approval gate in the publish flow. It decided whether to ask for approval by checking whether .ai/ is tracked, using that as a proxy for "team repo." The proxy was wrong in the direction that matters: rulesets, home, and work all track .ai/ while all three are private single-user repos, so the rule skipped the gate on the three projects I use most. It now checks whether any remote is on a host other than cjennings.net, which is the thing that actually decides whether someone else reads the log. Every current project resolves to gate-applies, which matches how the flow has actually been run.

I also added a first-person directive to the always-loaded core. One already existed for commit bodies and PR prose, but it moved into the publish skill with everything else, and it never covered code comments at all. Now everything I author in or about the repo is first person, with one carve-out: a comment describing what the code does stays third person, because there the code is the actor and not me.

Separately I split the publish skill internally. PR descriptions and the three review shapes moved to references/pull-requests.md, since a plain commit never needs them.

Always-loaded rules are now about 28,900 tokens, down from 57,800 this morning.

One risk on the record. testing.md's margin is thinner than commits.md's was. If testing-standards fails to trigger while I'm writing tests I lose the mocking-boundary rules, which is a quality regression rather than a permanent one, but it is a real bet where commits.md's was not. I also moved the TDD rationalization table rather than cutting it. The posts argue that kind of over-argument is counterproductive now, but removing your defense against me skipping TDD is your call, not mine.
</content>
</entry>
<entry>
<title>refactor(rules): split commits.md into invariants plus a publish skill</title>
<updated>2026-07-27T18:58:35+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-27T18:58:35+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=931f364864441676eb250cecd074c4876012a1cd'/>
<id>urn:sha1:931f364864441676eb250cecd074c4876012a1cd</id>
<content type='text'>
commits.md was 12,800 tokens, larger than the next four rules combined, and it loaded in every session in every project whether or not anything was being published. Most of it is procedure that only applies when a commit or PR is in play.

I split it by blast radius rather than by size. What stays always-loaded is what must hold whether or not I'm publishing, and where a violation is permanent and reaches other people: author identity, the no-AI-attribution ban, the generated-document byline rule, and the public-artifact content-scope rules. That core is 1,027 words, about 2,342 tokens.

What moved into the publish skill is everything about how a message gets written, reviewed, approved, and published. The pre-flight reconcile, the code-review gate, the draft and voice and approval gate, conventional-commit format, Voice and Focus, PR description structure, the three review shapes, merge strategy, and the pre-commit checklist.

The failure modes are why the line falls there. If the skill doesn't load I don't know the publish flow and have to be told, which is visible and recoverable. I don't silently commit with AI attribution, because that guard never moved. Only the recoverable half is on the skill-triggering bet.

I verified the split by using it. The skill registered mid-session and I invoked it to publish this commit, and it loaded with the flow intact.

Content is conserved: 5,561 words in, 5,898 across both files, the difference being the frontmatter and the pointer I added to the core. I repointed five cross-references in voice, review-code, inbox.org, and no-approvals.org that named sections which moved.

One thing I left alone. The skill is a single 4,871-word file, and both posts argue a long skill should split internally rather than sit as one blob. It loads on demand now, which is the win worth taking today. Splitting it further is its own change.
</content>
</entry>
<entry>
<title>fix(hooks): don't let a wrap sentinel outlive its session</title>
<updated>2026-07-27T18:32:34+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-27T18:32:34+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=2c664cb5651fbf03bc52d7848add0c571708adcd'/>
<id>urn:sha1:2c664cb5651fbf03bc52d7848add0c571708adcd</id>
<content type='text'>
wrap-it-up drops /tmp/ai-wrap-teardown-&lt;project&gt; to ask the Stop hook to kill the tmux session once the wrap certifies clean. When certification fails the Stop hook blocks and leaves the sentinel armed, which I did on purpose so a wrap blocked by a dirty tree can retry on a later stop without re-running the workflow.

I never bounded that retry to the session. An uncertified sentinel sat armed indefinitely and fired in whatever session next reached a clean tree. work's 11:37 wrap today left one armed. The 13:20 session ran startup, committed the task filing and the template sync, went clean, and the next stop consumed the two-hour-old sentinel and killed the terminal mid-work. Every stop before those commits had been blocked by the same sentinel failing certification, so the session spent its whole life either blocked or dead. archsetup's had been armed since Saturday on a live attached terminal, and home's was armed and waiting.

session-start-disarm.sh clears the project's sentinels at SessionStart. A new session means the wrap that armed one is gone, so its pending teardown is meaningless. Within-session retry is untouched, since the hook only runs at session start, and a test pins that. If teardown is still wanted, wrap-it-up re-arms it.

I disarmed the three live ones by hand before writing this, backed up under /tmp/disarmed-sentinels.

Four tests cover the disarm, one pins the retry behavior I did not want to lose. The scoping test matters most: a concurrent session in another project keeps its own sentinel.
</content>
</entry>
<entry>
<title>feat: add peer-reasoning contract and fix two silent probe defects</title>
<updated>2026-07-27T15:48:12+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-27T15:48:12+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=6c1ea8bbc3775fe7c481f41b4f30e0c0110a9339'/>
<id>urn:sha1:6c1ea8bbc3775fe7c481f41b4f30e0c0110a9339</id>
<content type='text'>
I added a Collaborative Peer Reasoning section to the interaction rules. It governs how an interpretation forms before any rule about presenting choices: infer first and clarify only at material forks, test a conclusion against its strongest alternative, let a correction update the downstream model instead of just the wording. The file's framing line widened to match.

Two probes were failing silently. The startup KB nudge looked up the best-practices node by grepping file content for its slug. A roam node's slug lives in its filename, so the lookup always returned empty. The nudge pointed at nothing in every project and every session, for as long as it shipped. It matches the filename now, through find rather than a glob so zsh doesn't abort on no match.

The browser rule told agents to open URLs with a form ending in &amp;&gt;/dev/null &amp;. That discards the "Opening in existing browser session." line confirming the tab opened. Warm and cold start now split: foreground and read the confirmation when Chrome is already up, detach only when it isn't. The confirmation is on stdout, verified rather than assumed.

I filed two tasks from handoffs. The sentry triage split needs a work-vs-personal classification mechanism before its wording can move, because the current rule excludes by category and category can't express that split. The publish-lock design is approved but carries three open gaps. The load-bearing one is a lock held across an unbounded human approval pause.

I also swept the old processed handoffs out of inbox/. History keeps them.
</content>
</entry>
<entry>
<title>feat: enforce clean wraps and inbox-safe sync</title>
<updated>2026-07-25T20:35:14+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-25T20:35:14+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=f2609d9f9ad33486bef43211d753ba53e1e24181'/>
<id>urn:sha1:f2609d9f9ad33486bef43211d753ba53e1e24181</id>
<content type='text'>
Centralize repository-state checks, bind teardown to a certified clean HEAD, and allow inbox-only refreshes. Guard installed symlinks from cross-project writes and add regression coverage.
</content>
</entry>
<entry>
<title>docs: define speedrunnable backlog tasks</title>
<updated>2026-07-25T20:34:56+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-25T20:34:56+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=3203bd803b6a05d10781634e7e18742879c1046a'/>
<id>urn:sha1:3203bd803b6a05d10781634e7e18742879c1046a</id>
<content type='text'>
Clarify that speedrunnable means the solo autonomy tag; quick remains only an effort hint.
</content>
</entry>
</feed>
