<feed xmlns='http://www.w3.org/2005/Atom'>
<title>rulesets/claude-templates/.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-08-05T23:22:37+00:00</updated>
<entry>
<title>feat(rules): quiet output, and a dispatch gate for where output lands</title>
<updated>2026-08-05T23:22:37+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-05T23:22:37+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=588fbf6b3adca048afbf57ef616cc3558310fff2'/>
<id>urn:sha1:588fbf6b3adca048afbf57ef616cc3558310fff2</id>
<content type='text'>
Tool output isn't free. It lands in the Emacs buffer I'm working in, so eleven hourly sentry cycles on 2026-08-05 filled my workspace with lines confirming nothing had changed.

Two rules, each in the file that already owns the concern. interaction.md gets quiet output: a check returning the expected result prints nothing, and only deviations plus one summary line reach the terminal. It already governs styling, and volume is the larger cost.

subagents.md gets a third dispatch justification beside cost and isolation. A repeated scheduled pass is dispatched for where its output lands, not for how hard the work is. The size gates don't apply, since a recurring check battery is always a known target in under ten calls.

Verification gates are exempt, and that exemption is load-bearing. An exit code can lie, so a check that is evidence for a completion claim still gets read in full.

sentry.org's pass runner now runs the walk in a background subagent, and says where the thread boundary falls.
</content>
</entry>
<entry>
<title>docs(triage): mark the Gmail date-slice walk as a floor, not a complete read</title>
<updated>2026-08-05T22:49:17+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-05T22:49:17+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=57dae70a07be8fec4fef27a7382034709fe3807c'/>
<id>urn:sha1:57dae70a07be8fec4fef27a7382034709fe3807c</id>
<content type='text'>
The walk bottoms out at day resolution, so a single day over the 100 cap still truncates with nothing to signal it. Three slices reported about 250 messages where REST pagination found 291.

A routine sweep tolerates that, because the next one re-reads live unread state. A backlog inventory can't. No tool ships here for real pagination, so a count taken through this MCP has to be reported as a floor.
</content>
</entry>
<entry>
<title>fix(triage): pass a callback to telega--loadChats so scans stop printing 404s</title>
<updated>2026-08-05T22:49:17+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-05T22:49:17+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=7637a97ad50d24144f91aae7287194736e73ca5a'/>
<id>urn:sha1:7637a97ad50d24144f91aae7287194736e73ca5a</id>
<content type='text'>
telega--loadChats sends fire-and-forget when its callback is nil. The reply then matches no :@extra and falls through to telega--on-error, a catch-all whose whole body prints the message.

TDLib returns 404 here to mean every chat is already loaded, so the line was noise we generated ourselves. It printed twice a scan with nothing actually wrong.

Passing #'ignore consumes the reply. The call stays asynchronous, since the block-and-wait path is reachable only with a nil callback.
</content>
</entry>
<entry>
<title>fix(daily-prep): gate the priorities review on a script per standup</title>
<updated>2026-07-31T18:19:18+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-31T18:19:18+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=a212eeb7c92f291045344f2769c9a7f85f2a38d5'/>
<id>urn:sha1:a212eeb7c92f291045344f2769c9a7f85f2a38d5</id>
<content type='text'>
A prep wrote "Bring:" topic lists under both standup headers. Every rule requiring a Yesterday/Today/Blockers brief was already on the page. More prose in Phase 6 wouldn't have caught that, because the prose is what got skipped. So the requirement now sits on the Phase 8 gate: the prep isn't complete until every standup carries its own script.

The Standups section now says the lines are the words I speak, in complete first-person sentences. Two standups get two scripts, because the altitude rules admit different material to each. A topics-vs-script contrast sits with the shape rules, since prose decays back into bullets and an example doesn't.

The script is a draft I edit at the gate, never words put in my mouth. A topic list fails safe by forcing me to compose. A wrong script reads fluently enough to be spoken unchanged.

Refs: inbox/2026-07-31-1129-from-work-proposal-for-daily-prep-org-standup.org
</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>feat(ai): add --helper for a second session in a live project</title>
<updated>2026-07-30T12:42:47+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-30T12:42:47+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=84bd121683add4440a3652b12b70e6e2d45f5384'/>
<id>urn:sha1:84bd121683add4440a3652b12b70e6e2d45f5384</id>
<content type='text'>
A helper is a second agent session in a project that already has a live one. It reads freely, makes only scoped single-heading edits, and never touches git. Until now the only way to get one was to tell a session by hand it was a helper, and nothing checked whether that was true.

--helper runs agent-roster first, so the claim gets verified rather than asserted. It opens its own tmux window and hands the session helper-mode.org instead of the normal startup workflow. Git prep is skipped because pulls belong to the primary. With no other agent live it warns and falls back to a primary launch.

When the roster can't answer, it opens a helper anyway. I picked that direction because a helper that turns out to be alone merely does less, while a primary that turns out not to be alone runs pulls and rsync underneath a live session.

Wrap-up gains a Step 0 branch. A helper archives its own context file and stops, and the primary's next commit carries its edits. An orphaned helper whose primary already exited runs the full wrap, because otherwise its edits strand as a dirty tree nobody owns.

That branch resolves the project root before consulting the roster. agent-roster defaults to $PWD and only matches agents at or inside it, so a call from a subdirectory would miss a primary at the root and report the helper orphaned. That is the one path that commits and pushes.

The agent id is sanitized to the charset session-context-path keeps, because it's interpolated into the command typed into the pane. A helper launching a helper gets a fresh id rather than inheriting its parent's. Two agents resolving to one context file is the collision the contract exists to prevent.

Startup still has no roster check, so a bare claude launched into a busy project runs full primary startup regardless. helper-mode.org and protocols.org now say so instead of listing the routing as though it works.
</content>
</entry>
<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>
</feed>
