<feed xmlns='http://www.w3.org/2005/Atom'>
<title>rulesets/hooks, 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-27T18:32:34+00:00</updated>
<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: 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>feat(hooks): soft-nudge pending inbox handoffs at task boundaries</title>
<updated>2026-07-20T02:04:43+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-20T02:04:43+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=94e54f69453dea0feda4722347ba3c60c9822c2b'/>
<id>urn:sha1:94e54f69453dea0feda4722347ba3c60c9822c2b</id>
<content type='text'>
The "check inbox/ at every task boundary" rule was prose-only in protocols.org, so it held only as well as the agent remembered it and a handoff could pass a turn unseen. inbox-boundary-check.sh is a Stop hook that backs the rule: when inbox-status -q reports pending items it blocks the yield once and injects the count, so the agent processes them before returning.

It soft-nudges rather than hard-blocks. On the harness re-entry (stop_hook_active: true) the hook steps aside and lets the turn end, so a mid-task pause to ask a question, or an item the agent genuinely can't process, never wedges the session. A hard block would instead push inbox processing ahead of that clarifying question.

The hook self-skips where it doesn't apply: no inbox/ dir, no inbox-status, or a clean inbox each exit 0 silently, so one global hook covers every project with no config. It prefers the project-local .ai/scripts/inbox-status and falls back to one on PATH. It's wired into the Stop array ahead of ai-wrap-teardown in both the tracked settings.json (which the live ~/.claude/settings.json symlinks to) and the documented snippet. bats covers pending, clean, the re-entry step-aside, no-inbox, and the absent-status degrade.
</content>
</entry>
<entry>
<title>feat(hooks): block bundled test+commit, require full suite before commit</title>
<updated>2026-06-28T16:24:59+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-06-28T16:24:59+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=92dfc355d2292c6d6c17a51cf2f83b8ba033596a'/>
<id>urn:sha1:92dfc355d2292c6d6c17a51cf2f83b8ba033596a</id>
<content type='text'>
I tightened the before-committing rule in verification.md and commits.md from "run tests" to "run the full suite as its own step and commit only on zero failures." A PreToolUse hook now backs it: it denies a test runner chained into git commit through an ungated connector, and lets the gated &amp;&amp; form pass. I added a respond_deny helper. Tests cover the connector cases.
</content>
</entry>
<entry>
<title>feat(wrap): add session teardown and shutdown to wrap-it-up</title>
<updated>2026-06-24T03:34:18+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-06-24T03:34:18+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=f87f59cc9eb1dd492be5b55870271d67245c1fdd'/>
<id>urn:sha1:f87f59cc9eb1dd492be5b55870271d67245c1fdd</id>
<content type='text'>
A bare "wrap it up" now tears the session down after the valediction: it kills the ai-term buffer and the aiv-&lt;project&gt; tmux session (which takes claude with it) and restores geometry. "wrap it up with summary" or "and summarize" keeps the buffer. "wrap it up and shutdown" gates on this being the only live ai-term session, then powers the machine off through an abort-able Emacs countdown.

Teardown can't run inline because it kills the session claude runs in, so the valediction would never flush. Step 6 instead drops a basename-keyed sentinel after commit+push is verified, and a new Stop hook (ai-wrap-teardown.sh) does the teardown when the response ends, by which point the valediction has rendered. The hook is a no-op on every normal stop because the sentinel only exists after a teardown wrap.

The runtime lives in .emacs.d/modules/ai-term.el (cj/ai-term-quit, cj/ai-term-live-count, cj/ai-term-shutdown-countdown), and the rulesets side calls it via emacsclient. I routed that companion to .emacs.d, so the feature is end-to-end once it lands. The hook has 8 bats tests. The live teardown and shutdown paths are a manual checklist in todo.org.

Built from the proposal. I went with both summary qualifiers, the Emacs-timer countdown, and the live-count gate.

Claude-Session: https://claude.ai/code/session_017PtX1nt1rtYVATuzmzBS4f
</content>
</entry>
<entry>
<title>feat(hooks): title sessions host-project with a hyphen, no space</title>
<updated>2026-06-13T18:49:21+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-06-13T18:49:21+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=f537150ff3f67899d27a7f121bc302f61a307c1c'/>
<id>urn:sha1:f537150ff3f67899d27a7f121bc302f61a307c1c</id>
<content type='text'>
The SessionStart hook joined host and project with a space ("ratio rulesets"), which reads as two words in the claude.ai/code and mobile session lists. I changed the join to "$host-$project" ("ratio-rulesets") so the title is one token, and updated the three session-title-hook.bats expectations test-first.
</content>
</entry>
<entry>
<title>feat(hooks): title sessions "host project" for the remote session list</title>
<updated>2026-06-11T18:23:13+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-06-11T18:23:13+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=bdc9a5d6e1320032770f54c747c210e4f465c399'/>
<id>urn:sha1:bdc9a5d6e1320032770f54c747c210e4f465c399</id>
<content type='text'>
Remote sessions showed up on claude.ai/code and mobile under auto-generated names, so picking the right one meant guessing. Claude Code 2.1.152+ lets a SessionStart hook set the title via hookSpecificOutput.sessionTitle.

hooks/session-title.sh emits "&lt;uname -n&gt; &lt;project&gt;" (ratio rulesets, velox work) on startup and resume. Project is the git-toplevel basename so a session started in a subdirectory still names the project, with the cwd basename as fallback. The hook stays silent when a title already exists, so a /rename or an earlier run isn't clobbered on resume. The harness ignores titles on clear and compact, so the settings matcher restricts to startup|resume.

Wired in settings.json and the install-hooks snippet. As a default hook it reaches every machine through make install on the next session start.
</content>
</entry>
<entry>
<title>feat(flush): read notes.org before the anchor on resume</title>
<updated>2026-06-03T02:14:56+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-06-03T02:14:56+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=2433e74131582e7010fba4e561f40d793bdc5334'/>
<id>urn:sha1:2433e74131582e7010fba4e561f40d793bdc5334</id>
<content type='text'>
On a flush resume, the SessionStart(clear) hook now reads .ai/notes.org key sections before the session-context anchor. The anchor carries session state. notes.org carries the project's standing knowledge (code-repo paths, conventions, key contacts) that a resumed session needs to act correctly. A resume with the anchor alone floundered on context notes.org already documents, hunting for a repo whose path notes.org records.

The hook guards on notes.org presence: when it's absent, the resume reads the anchor alone. flush/SKILL.md documents the same read order so the skill and the hook agree. The handoff rationale is preserved in docs/design/2026-06-02-flush-promotion.org.
</content>
</entry>
<entry>
<title>feat(flush): add /flush skill and SessionStart(clear) resume hook</title>
<updated>2026-06-03T01:36:18+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-06-03T01:36:18+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=526df6dd7872890e525b9af33917169cfdf705d8'/>
<id>urn:sha1:526df6dd7872890e525b9af33917169cfdf705d8</id>
<content type='text'>
Flush is the checkpoint half of the wrap/restart rhythm. It refreshes the session-context anchor in place, the user runs /clear, and the session resumes from the anchor instead of starting cold. One logical session stays alive across a /clear boundary without the archive-and-commit of wrap-it-up or the full cold boot of startup, which buys cheaper tokens and a sharper context window.

The mechanism splits into two halves around /clear, which wipes the conversation so nothing runs straight through it. The /flush skill is the pre-clear half: dump live state, refresh the anchor's Summary, append a dated flush marker, verify the write landed, then prompt the user to /clear. The agent can initiate at a clean task boundary on its own judgment, but /clear is user-only, so the agent does the work and the user supplies the single keystroke. The session-clear-resume.sh hook is the post-clear half, a SessionStart matcher=clear hook that points the fresh session at the anchor to resume, or at startup when no anchor exists.

I packaged the pre-clear half as a skill rather than a project-workflow doc so both halves are global. The hook was already global, so /flush is now callable by name from any project with no per-project sync.

The hook is canonicalized under hooks/ and symlinked into ~/.claude/hooks/, matching precompact-priorities.sh. settings.json wires the SessionStart entry, and settings-snippet.json carries it so a fresh machine wires the hook on make install-hooks.
</content>
</entry>
<entry>
<title>feat(hooks): scan file-backed messages and harden rm parsing</title>
<updated>2026-05-22T20:38:58+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-05-22T20:38:58+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/rulesets/commit/?id=53d33d9cdd5c6d7fd7c4dc7315b04d225add94d6'/>
<id>urn:sha1:53d33d9cdd5c6d7fd7c4dc7315b04d225add94d6</id>
<content type='text'>
Two audit gaps in the confirmation hooks, plus the test harness they were missing.

The git-commit and gh-pr-create hooks scanned for AI attribution but only saw inline messages. A commit made with -F/--file or a PR made with --body-file slipped through, since the hook stored a placeholder instead of the file's text, and the publish flow uses -F constantly. A new read_referenced_file helper in _common.py reads the referenced local file (missing, oversized, or non-UTF-8 returns None, which means "couldn't inspect" and never "clean"), so attribution scanning now sees the real committed and posted text. An unreadable file falls through to the existing ask-anyway path.

destructive-bash-confirm.py parsed rm flags by splitting on whitespace, which mangled quoted paths and missed flag variants. detect_rm_rf now tokenizes with shlex, so quoted or spaced paths and combined, separate, or reordered flags all parse. It fails toward asking (a sentinel that still fires the modal) on unbalanced quotes, or when a forced recursive rm sits alongside a pipeline, compound command, substitution, or redirect, since target attribution isn't trustworthy there. The supported and unsupported shell constructs are documented in the docstrings.

These hooks had no tests and weren't in make test. Added a pytest harness under hooks/tests (an importlib-by-path loader, since the hook filenames are hyphenated) with 54 tests across the three hooks and the shared helper, and wired hooks/tests into make test. Full suite green.
</content>
</entry>
</feed>
