Changelog
All notable changes to this project are documented here. The format follows Keep a Changelog, and the project follows semantic versioning.
Unreleased
0.2.0 (2026-09-24)
Breaking
0.2.0 versions the contract and takes its break here, in seven parts: the undecided rule for not
and absent; schema versions, on detector entries (below) and on rows and the report_data result
(under Added); the persisted fold map for renamed detector ids; the declared public API; the
coverage block, which now also appears in report --json as coverage; section rules; and the
report’s frequent marker, with its threshold renamed. Each part has its entry below, the row
schema’s under Added. From 0.2.0 nothing declared breaks within a minor series, and a later 0.x
minor may break only under a Breaking heading like this one; the README’s Versioning section states
the policy.
- A declarative matcher no longer counts a Bash command the shell parse skipped - empty or missing,
longer than 16,384 characters, or one that does not tokenize or parse - as a match for
notorabsent(#19). Over such a command everycommandkey butregexandunparsed, everygitkey, everyenvkey and atextread ofsource: heredocsis undecided;not,anyandallpass undecided through, and an undecidedwhen, anorderendpoint or anabsentcandidate is no hit. A detector file usingnotorabsentmay count fewer hits; no edit is needed, and a rule that wants those commands asks forcommand: {unparsed: true}. A predicate fromcompile_matchermay now return a falsy undecided value: a Python caller who negates a predicate itself reads it as false and can over-count, and should test it withis_undecidedor compose through the declarativenot,anyandallinstead. - A detector file’s top-level
version, ignored in 0.1, is now read as its entries’ schema version, and an entry may carry its ownschema_version, which wins (#32). A schema version this release does not know - above 2,0, negative or not an integer - is a finding, and only the entries that would read it are skipped; a top-levelschema_versionis a finding too, since the file-level key isversion. A 0.1 detector file whose top-levelversionis anything but 1 or 2 loses the entries without a key of their own until it is corrected. report --rulesprints the measured share in the coverage block, as inrules: 2 measured, 1 dark, 1 unmeasured (50% measured), floored so a file with a rule unmeasured never shows 100% (#47); andreport --jsonnow carries the block as a top-levelcoveragekey, where it was printed to stderr only. The key counts rules, not sessions.- The README declares the public API: every name, call shape and non-name promise a downstream tool may pin, each held by a contract test that CI also runs against the built wheel imported as a zip (#35). A name outside that list is importable but may change in any release.
- A retired detector id folds onto its current id through one fold map: the renames the package
ships, with a consumer’s own
Registry(renamed=)laid over them (#34). Chains resolve to their end, and a cycle raisesValueErrorwhen aRegistryis built or a rename would close one.report_dataand--jsoncarry the effective map asrenamed, and corpus labels fold the same way. A test fails when a detector id any release shipped is neither registered nor folded. - An unbound rule file with headings now splits into one rule per section, with the id
<path>#<heading-slug>and an ordinal suffix for a repeated slug, so the coverage block counts rules, not files (#48). A section holding only a heading, a fenced or indented code block, a table, a quote or a comment is not a rule. A file with no heading, a file bound in its front matter, and a headed file with no rule section stay one rule with their 0.1.0 id. The findings line now readsfindings: N (everything else still loaded). - The
reporttable marks a detector whose share of measured sessions passes the thresholdfrequent, where 0.1 printedpromote?, and the marker advises nothing about the rule (#43). The threshold is renamed with it:frequent_share=onreportandreport_data, the--frequent-shareoption, thefrequent_sharekey inreport_dataand--json, and theRULE_FREQUENT_SHAREconstant, where 0.1 hadpromote_share,--promote-shareandRULE_PROMOTE_SHARE.
Added
- Rows and the
report_dataresult carryschema_version, which this release writes as 2 (#33). A row with no version, or a null one, reads as 1; a row with a version this release does not know is left out of every count, tallied inunknown_schema, and noted byreport. is_undecided(value), exported from the package root, tells the undecided result of acompile_matcherpredicate from a false one (#35).Detectortakes an optional keyword-onlyopportunities: a callable over(events, ctx)returning(turn, tool_use_id, followed)triples, withfollowedtrue, false or None for undecided (#41). Declarativeorderand turn-scopedabsentdetectors set it from the same evaluation as their hits; the positional constructor andfn(events, ctx)are unchanged.- A row from
measure()carriescompliance: for each enabled detector that defines opportunities,{"opportunities": N, "followed": M, "undecided": U}, with undecided points counted apart and never as not followed (#42). A raising or malformedopportunitiesis recorded inrules_errorstagged"hook": "opportunities"and costs only that detector’s compliance, not its hit figures. ruleprobe explainreruns the detectors over your transcripts and prints the event behind every hit: the session address, turn, tool-use id, detector id and the matched command or input, filtered by--session,--detectorand--key(#51). It changes no report number, sends nothing and writes nothing. Every line it prints passes throughredactinruleprobe.detectors.common, which hides the shipped secret shapes and a set of common credential forms; other credentials can still print, so read the output before sharing it.reportandreport_datacarry compliance beside hits for every detector a row has a tally for:opportunities,followed,undecidedandcompliance_rate, which isfollowed / opportunitiesand null belowmin_opportunities(#43). They group under--by repoand--by stanceas hits do, and the table prints them as four columns only when some row carries compliance.--min-opportunities, defaulting toRULE_MIN_OPPORTUNITIES(20), is the fewest opportunities a rate is printed for.report_dataalso countsopportunity_errors,malformed_complianceandunreadable_compliance, andreportnotes each: a detector whoseopportunitiesraised, a tally that cannot be read, or a compliance map that names no detector. Each costs only compliance figures for those sessions; hits stand.ruleprobe label --session <runtime>:<id> --detector ID --key KEY --corpus DIR --name NAMEturns a wrong hit into a labelled negative (#52). It reruns the detector over that session, writes the one event behind the hit, redacted, toDIR/sessions/NAME.events.jsonl, and appends anearlabel toDIR/labels.yaml, creating it when missing, soruleprobe corpusscores the case; the command it prints does that. It is the only command that writes, and only under the directory it is given. It refuses a hit on a whole session, a gated detector, a hit that redaction would change, an existing file and a corpus that does not load, and on any failure it undoes what it wrote.load_corpusreads an.events.jsonlsession as written.- A shipped catalog of seven common rule shapes binds a rule section no front matter binds, so a
stranger’s own rules are measured without a detector written
(#49): running the tests after a change,
skipping hooks, force-pushing the default branch, installing with pip where another manager
is named, reading a whole file, a non-conventional commit subject, and committing a
secret-shaped file. A section binds the one entry whose anchored pattern matches its heading or
a sentence of its text; none or several leave it unmeasured, and a rule that states an
exception or permission, or a condition in the matching sentence, binds nothing. A file with no
heading, or no rule section, binds by its whole text. The report notes each bound rule
catalog-bound,report --json’scoveragecounts them ascatalog, andruleprobe detectorsmarks the catalog’s entries.testing/test-after-changecounts followed opportunities as its hits, so its compliance columns carry the rate a change was tested. - Four declarative matcher keys:
git.arg_regexandgit.message_regexread one parsed argument and the first commit message, andcommand.programandcommand.first_operandread a segment’s program by its basename and its first operand past flags (#49). - Gemini CLI is the third runtime:
--runtime geminireads its sessions from~/.gemini/tmp, andautoincludes them (#55). Its shell tool reaches detectors asBashwhen the project root is a POSIX path, andwrite_fileandreplaceasWriteandEdit; other tools stay native. What its transcripts do not let ruleprobe measure is stated in the README: compaction, which leaves no marker; shell commands on a Windows or unknown root; model changes, which its router and quota fallback make unasked; and subagent calls, whose arguments are unchecked. - The shipped corpus holds labelled Gemini CLI sessions: two on a POSIX project root, with positives and near-misses for every detector that applies to Gemini, catalog entries included, and one on a Windows root that checks no shell detector fires there (#56). Every applicable detector scores at or above the 0.9 floor on them.
- The declarative
gitmatcher gainsconfig_regex, matched against the effective value of each-c key=valuegiven before the subcommand, andruleprobe.shell.git_configreturns those values (#88).
Changed
- The package declares its licence as the SPDX expression
MIT, withLICENSEas its licence file, in place of the deprecated table form and classifier (#6). Building ruleprobe from source now needs setuptools 77 or newer. - The
reporttable’s share header is one column wider, so the note and validity columns start under their headers (#43). load_corpusrefuses two session files of the same name in different subdirectories ofsessions/with aCorpusError, where the later one silently replaced the earlier (#52).RuleEntrygains a sixth field,source(ownorcatalog), andload_bundle().detectorsleads with the catalog detectors a rule bound; code that unpacks a rule entry into five names needs a sixth (#49). Anorderdetector now evaluates in one pass over the session, with the same hits and opportunities.
Fixed
- A Claude Code subagent’s own transcript is read as its own session, keyed
<parent session id>/<agent id>, where every line of it was skipped as another agent’s work and the file read as an empty session (#86). Subagent tool calls are now measured. A transcript that yields no events, from either runtime, is no longer a session: it is counted apart as holding none, so an empty file no longer adds to a share’s denominator. - A compiled
orderdetector whosefirstopens on events with no tool-use id, such as tool results, messages or prompts, keeps its compliance; its opportunities were refused asMalformedOpportunitieswhen two such events fell in one turn (#91). A repeated point with no id now counts once per occurrence, while a repeated point with an id is still refused. - A session copied into several transcript files, as when a project folder is copied or renamed,
is counted once: one session per runtime and session id, the file with the most events kept
(#87). Each copy set aside is named apart
from read failures on stderr, and
report --jsonlists them under a newcopieskey whileread_errorskeeps failures only.ruleprobe corpusstill reads each labelled file as its own session, since labels name files. - The shared shell parse reads a quoted or escaped operator as a word, as a shell does:
find . \( -name '*.bak' \) | headis onefindpiped intohead, where it split into three segments and counted as an unfiltered find (#98). The same holds for\;,\|,\&,\<,\>and quoted forms, sopipelines()keepsfind . -exec cat {} \;as one segment; a quoted or escaped reserved word stays a command word, andnormalisekeeps thecdincd a\&& ls. An unquoted operator still splits. - A Gemini CLI write the user edited before accepting it is no longer read as the agent’s write: it keeps its native tool name and its text is dropped from the event, so it is not measured as a write (#102). Gemini records the user’s version in the call and cannot be relied on to keep the model’s.
verification/no-verifycounts acore.hooksPathoverride only when it disables hooks, an empty value or/dev/null, reading the last value given for the key; pointing it at a tracked hooks directory such as.githooksturns the repository’s hooks on and no longer counts (#88). Every git detector now steps over git’s valued global options (--git-dir,--work-tree,--namespaceand the like), sogit --git-dir .git push --forceis read as the push it is.
Measured
- Field precision on real hits, from one developer’s own Claude Code and Codex transcripts: up to
50 hits sampled per detector, every hit when fewer, each judged right or wrong against the
detector’s own claim (#53). The judgment was
delegated to agents, not made by hand, and it measures precision, not what a detector misses.
Each figure is right over judged; hits the evidence could not decide are counted apart as unsure.
cache-hygiene/compact1.00 (22 of 22),cache-hygiene/model-switch1.00 (42 of 42, 1 unsure),transcript-hygiene/whole-file-cat0.98 (94 of 96, 2 unsure),commits/non-conventional-subject1.00 (48 of 48, 2 unsure),git-safety/force-push-default1.00 (3 of 3),package-manager/pip-install1.00 (18 of 18) andtesting/test-after-change1.00 (44 of 44, 6 unsure).secrets/secret-in-writescored 0.05 (1 of 21, 2 unsure): it fires on writing about secrets, and its redesign follows 0.2.0. Before this release’s fixes (#88, #98),verification/no-verifyscored 1 of 6 andtranscript-hygiene/unfiltered-find0 of 1, every hit it had;secrets/secret-file-addhad no hits to judge. - Time to first answer:
ruleprobe report --rules <dir> --since 30over a synthetic reference volume of 500 sessions and about 200 MB finished in under 1.5 s on one laptop, the worst run on the Python 3.9 floor (#50).
0.1.0 (2026-09-22)
Added
- The measurement engine:
run()over a session’s events, aRegistryofDetectorobjects, andreport()over the rows it produces. - Readers for Claude Code transcripts and Codex rollouts, behind one event schema and one
iter_sessions(root, runtime, since). - The Bash decomposition every shell detector shares: compounds, pipelines, heredocs, substitutions and continuations, parsed once per command.
- Six generic detectors: whole-file cat, unfiltered find, no-verify, secret in a write, compaction, and a model switch mid-session.
- A
ruleprobe reportcommand, grouping by detector, by repository or by stance. - An extension point for declarative detectors:
register_compilerandfrom_spec. - A declarative detector format, so a rule can be measured without writing Python: one entry
of
id,rule,event,whenand an optionalgate, compiled byruleprobe/matchers.pyinto the sameDetectora Python detector builds. - Matchers for a tool name, an argument, a Bash command through the shared parse, a
gitcall, an environment assignment, a text body, an assistant message and a raw event kind, composed withany,allandnot; and three that read a whole session,order,absentandchange. ruleprobe/declarative.py: a strict, minimal YAML subset with a line number and a reason on every refusal, and the same objects from a.jsonfile. No third-party dependency.- Detector files found without a flag:
.ruleprobe/detectors.yamlat the root of the repository you are in, and~/.config/ruleprobe/detectors.yamlfor the user.--detectorsnames one,--no-configskips discovery. ruleprobe report --rules <dir>: markdown rule files bind to the detectors in their front matter, and the report says which rules are measured, which are dark byopt_out, and which are unmeasured - the gap an author cannot otherwise see.ruleprobe/detectors/common.yaml: the six shipped detectors written as data, with a test asserting they produce identical hits to the Python reference over the corpus and the fixture transcripts.- A worked example that runs from a clone:
docs/rules/anddocs/example-session.jsonl. - A labelled corpus, shipped as package data at
ruleprobe/corpus/: six synthetic sessions in both transcript shapes, every interesting event labelled by hand inlabels.yamlwith the detectors that should fire on it, and a deliberate near-miss beside each - acatof a range, a filteredfind, a push after the gate ran, a heredoc withrm -rfin its body as text. Every shipped detector carries at least five positives and five negatives. ruleprobe corpus, andruleprobe.validity()behind it: per-detector precision, recall and F1 with their counts, a total,--json,--corpus DIRfor a corpus of your own, and a non-zero exit under--floor 0.9. The floor is this repository’s CI gate, not a runtime failure: nothing inruleprobe reportreads it.ruleprobe report --validity: each detector’s corpus precision and recall beside its row. Off by default, because the two numbers belong to the detector rather than to the run.- An optional
examples:block on a declarative detector -fire:andskip:lists of minimal cases, eachbash:,event:orevents:- so a detector of your own can be scored without a corpus. A detector with neither saysno examplesrather than a number, and the floor steps over it. .github/workflows/ci.yml: the unit tests on 3.9 and the newest 3.x,compileall, andruleprobe corpus --floor 0.9.
Changed
- A malformed detector entry is a finding with a file, a line and a reason, printed under the report; the entry is skipped and every other detector still runs.
- The README no longer says detector validity is unmeasured; it prints the corpus table instead, and a test asserts the README quotes it byte for byte.
Fixed
Every finding of the pre-release review, #2,
each with the reviewer’s own input as a regression test in tests/test_findings.py.
- The shell parse.
cat <<\EOFis a heredoc header, so its body is no longer parsed as commands and no longer yields a falseunfiltered-findhit; a heredoc terminator must be the delimiter alone, as bash has it, and only<<-may have it indented; a command that does not tokenize isunparsedrather than invisible to every matcher. - The denominator. A detector that raises leaves the denominator of that detector only, instead of taking the whole row out of every other detector’s evidence, and a row is never counted twice in the preamble.
- Errors are counted.
iter_sessions(errors=[...])collects every transcript that raised or held no session, andruleprobe reportprints the count. The directory walks follow symlinks. - Matchers.
arg: {equals: ...}no longer raises on a list-valued field;absentover a session with no events is not a hit;command: {contains: ...}is a substring; a list ofcommand: {regex: ...}patterns is alternatives;order: {within: N}does not spend its budget ontool_resultevents;path_globis matched as a path, with*stopping at a/,**crossing one, and a relative pattern matching an absolutefile_path. - The parser. An escaped quote inside a double-quoted string no longer truncates it at a
#;[a: b]is a one-key mapping and not a tuple;yes,no,on,offand a leading-zero number are refused with a line and a reason rather than read one way here and another by YAML. - The readers. A runtime is chosen by parsing the first line, not by searching it for
"session_meta"; a streamed partial text block is one message that grows, not two; a Codex user message is auser_promptandfinalis derived there as it is on Claude Code, so a detector means the same thing on both.
Changed
iter_sessionstakes anerrorslist;measure,run,report,Registryanditer_sessionsare otherwise unchanged in signature.report_data(rows, ...)is new and is whatreport()renders andruleprobe report --jsonprints, so the table and the JSON cannot disagree about a denominator, a fold or a note.--jsonnow prints that object with the rows underrows, and exits non-zero on an empty root as the table does.ruleprobe report --stance dimension=variant, repeatable: agate:block was unreachable from the command line, so a gated detector never fired and--by stancecould only print(no stances).ruleprobe detectorsnames the stance each gate is waiting for.--since 2024is refused rather than silently read as 2024 days back.Registry.renameremoves the detector it renamed.- A
user_promptevent carries the prompt’stexton both runtimes. README.mdandruleprobe/detectors/common.yamlno longer claim the shipped detector file uses every matcher; they name the ten it uses and the four it does not.