Releases: rodlunt/engineering-audit
Release list
v0.15.0
What's Changed
- feat(integration): add Engineering Grill planning skill by @Charlie-McDaddy in #234
- Create the release rather than reporting its absence by @rodlunt in #240
- fix(interrogate): a subagent that reached no source must say so, not answer by @rodlunt in #241
- fix(install): install skills by copying, not by symlinking into the checkout by @rodlunt in #242
- feat(grill): fold interrogate into Engineering Grill as one pre-build skill by @rodlunt in #243
- docs: add footer link by @rodlunt in #244
- Config page UX: feedback destination stated, Select all for consent, structural launch signal by @rodlunt in #252
- Grill: diagnose MCP registration scope when the framework is unreachable by @rodlunt in #253
- Run signals: self-declared pack edition and confirmed-stale surfacing by @rodlunt in #256
- chore(release): 0.15.0 by @rodlunt in #257
New Contributors
- @Charlie-McDaddy made their first contribution in #234
Full Changelog: v0.14.0...v0.15.0
v0.14.0: every relevant domain, and a triage that can compare them
One change to interrogate (#233), and it is the difference between a sampler and a triage.
The three-domain cap was arbitrary, and it compared things that were never comparable
v0.13.0 picked up to three relevant domains and asked from those. Two problems, and the second is
the real one.
A cap is a guess about the work. Some work genuinely touches twelve domains and some touches two,
and trimming to a number to keep the cost tidy drops questions on the floor without saying so.
interrogate now judges every one of the sixteen triggers against the work and runs every domain
that fires, with no cap. It prints the split first, Relevant: and Not relevant: with a reason
on each, says how many sub-agents that means, and asks once before spending anything. That is the
only point where the cost is knowable in advance, so it is the only fair place to ask.
The deeper problem was ranking. Each domain ranked its own questions and the parent took the top
of each, which silently assumes every domain's best question is worth the same. It is not. One
domain's third-best question can matter far more than another's first, and nothing in either
ranking says so.
Every derived question now carries reversibility (irreversible-once-shipped,
expensive-to-change, cheap-to-change) and blast_radius, naming in a few words what else has
to move if the answer turns out wrong. These exist so questions can be compared across domains at
all. The parent pools every question from every sub-agent and triages the three most impactful in
the entire set: everything irreversible first, widest blast radius first inside that, then
expensive-to-change. Three questions from one domain is the correct answer when that is where the
irreversible decisions are, and forcing a tidy spread buries a real question to make room for a
cosmetic one.
Saying how much was held back
The skill now states the arithmetic before the first question: how many questions came back, from
how many domains, and that three are being put now with the rest held. Three questions presented
without that sentence read as the whole interrogation, and the user calibrates their trust
accordingly.
The deep dive is then offered with the real number attached, and anything declined is recorded as
NOT ASKED and counted. Bail-out is unconditional: on stop, the record is written immediately, the
session is marked ended early, and the unasked count is given. A short session must never read as
a complete one.
Still BETA, and more so than v0.13.0 was
The cross-domain triage described above has never been run. Question derivation is proven on three
of sixteen domains; the pooling and ranking that this release is entirely about is new code paths
in a skill file that no full session has ever exercised. A question that reads as a generic quiz
rather than as being about the actual work is a defect to report, not something to work around.
SBOM attached, CycloneDX JSON, runtime dependencies only. It was generated by
tag-version-guard.yml when the tag landed but could not be attached at the time, because this
release did not exist yet.
v0.13.0: questions before the code exists
A second mode. audit sweeps code that exists; interrogate turns the same rules pack into
questions about work that has not started. Closes #229. Claude Code only.
The skill
interrogate (#230) uses list_domains and get_domain and nothing else. It never calls
begin_run, record_domain_result, file_issues or render_report, which is what lets it run
against a directory that is not a repository yet: those tools demand a repository name and a
commit, and work that has not started has neither.
It fans out one read-only sub-agent per relevant domain rather than reading domain documents into
the conversation. A domain document is far larger than its generated skill file suggests, because
the full document carries the Verification: trails that the skill files strip.
Also in #230: a sub-agent is now told explicitly that a domain spooled to a file is not a failed
fetch. A large domain exceeds the tool-result limit and comes back as Error: ... Output has been saved to <path>. The word Error is misleading, the fetch succeeded, and both plausible wrong
reactions are silent ones.
The plan-mode offer hook
integrations/claude-code/interrogate-offer.sh (#231) makes the skill discoverable. There is no
hook event for a permission-mode change, so it gates UserPromptSubmit on
permission_mode == "plan" with a session-keyed stamp, plus a PreToolUse leg on
EnterPlanMode.
It is advisory and never blocks, and that is a constraint rather than a preference: both blocking
channels on UserPromptSubmit erase the user's prompt outright. For the same reason, a hook like
this should be registered as bash "$SCRIPT"; exit 0, because a script with a syntax error exits
2 and exit 2 blocks.
#231 exists because #230 shipped a skill to a public repository while the thing that offers it sat
in private configuration. Outside users would have had a feature nothing could ever tell them
about. Nothing in the verification would have caught it, since every test ran on the machine where
the private half exists.
Marked BETA, literally
The label is not defensive. What has been exercised: question derivation on three of sixteen
domains against one invented brief, the no-run guarantee, and the hook's failure paths. What has
not: the other thirteen domains, and the interactive loop with a real person answering. Nobody has
completed a full interrogation, so the shape of a session is unproven and question quality is
sampled rather than measured.
Housekeeping
Version bumped through scripts/bump-version.py, so all 14 install pins and 2 prose mentions move
together. check-version-pins.py passes and 847 tests pass.
SBOM attached, CycloneDX JSON, runtime dependencies only. It was generated by
tag-version-guard.yml when the tag landed but could not be attached at the time, because this
release did not exist yet.
v0.12.0
Nine changes, most of them found by the tool being pointed at its own eval
harness rather than by review.
The eval spec can now say "either answer is acceptable" (#213, #199)
expect gains a third state, finding-or-not-applicable, with its own outcome,
ruled-out. It exists for a rule where the fixture genuinely admits two
defensible readings, so raising the finding and ruling the rule out both count.
It still refuses pass, could-not-evaluate and no verdict at all: those are the
auditor not engaging. The line is that a not-applicable verdict has had to carry
a note naming the precondition that does not hold since schema version 4, so it is
a position someone wrote down. could-not-evaluate is the shrug.
ruled-out exits clean but is counted and printed separately from hit, so a
score resting partly on the permissive state cannot read identically to one that
found everything outright. Exactly one expectation uses it.
EVAL_RESULT_SCHEMA_VERSION is 3.
Updating the pin now has a command that works (#219)
The old instruction was "change the tag and re-register", and it failed:
claude mcp add refuses to overwrite an existing name. Anyone following it hit an
error, and the natural recovery leaves them silently on the old pinned build,
which is the whole failure the pinning exists to prevent.
The path genuinely differs per host, and all three are now documented separately,
with the CLI behaviours verified live rather than assumed:
| host | update |
|---|---|
| Claude Code | remove, then add; refuses to overwrite |
| Codex | re-add; it replaces in place |
| Gemini | edit the tag in settings.json; no CLI step exists |
--scope user is documented too. Without it claude mcp add defaults to local
scope and registers the server for one directory only.
The stale-update check now says what to do about it
It detected a stale build and reported it, then stopped, while the remedy lived in
documentation that was itself wrong. It now names the command that clears it, keyed
on the host that asked. An unrecognised host gets a documentation pointer, never a
guessed command: a wrong command is worse than none, because a user who runs it and
sees no error concludes they have updated when they have not.
The summary-reads-clean guard covers the whole class (#189)
That guard was written after the fourth instance of a rendered summary reading
clean over a gap, and scoped itself to <summary>. The defect is not scoped to
<summary>, and both known blind spots have since been hit: a count in an <h3>,
and a table cell, which is where the fifth instance lived. It now tracks the
"N of M" idiom wherever it renders, across two modules.
Every report image rebuilt (#225)
The screenshots had drifted two generations behind the report they are pictures
of, still showing a Confidence column without the base clause the last release
added. The social card embeds one of them and was stale twice over.
A currency test now fails the suite when any of them falls behind again. It needed
fetch-depth: 0 in CI: on the default shallow clone every path resolves to the
same commit, so the comparison held trivially and the guard reported success while
checking nothing. That is the exact defect class the guard was written to catch,
occurring inside the guard, and only a strict xfail surfaced it.
The capture method now lives in docs/social-card/README.md rather than in a
commit message, which is why this went stale twice rather than once.
Verification
847 tests pass. Both recorded eval runs re-scored, with the scorer controlled
first each time: a run-state with every finding stripped must fail, and does.
Full changelog: v0.11.0...v0.12.0
v0.11.0
The first release shaped by a real audit of this tool's own eval fixture rather than
by a code review. All three changes below were found by running 0.10.0 against
evals/golden/repo and reading what came back.
Confidence claims now ship with their base (#211)
A domain's self-assessed confidence never renders on its own. It carries how many of
that domain's rules could not be evaluated, out of how many:
self-assessed confidence high (10 of 18 rules could not be evaluated)
The smoke run produced the first self_assessment data any run had carried, and put
beside each domain's verdict distribution it showed a domain reporting high over
more than half a domain it could not check, rendering identically to one that checked
almost everything. README promises a reader the opposite.
Confidence stays self-reported and stays unchecked by the server, which is
deliberate and documented. What changed is that the claim is now always readable
against the work it was made about.
Malformed finding locations are refused on write, tolerated on read (#216)
Finding.location documents three forms: path, path:line, path:start-end. A
live run produced reports/charts.py:16,29, which is none of them and was accepted
in silence: the end-anchored suffix regex did not match, the whole string became the
path, the line numbers were never validated, and the eval scorer could not strip the
tail, so a correctly located finding scored as misplaced.
record_domain_result now refuses it and names the documented format. The check is
deliberately not on the model, because that is also what a stored run-state.json is
read through: enforcing it there would refuse every run already on disk.
Behaviour change. An auditor that records such a location now gets a hard error
where it previously passed straight through. This is why the release is minor rather
than patch.
Eval coverage 11 to 14 expectations (#215)
Three findings that the runs produced consistently, and that were not planted, are
now golden expectations: D01-R04, D05-R05, D16-R10. Each required a primary citation
that supports the claim and reproduction across both runs, and each says in its own
why that it was found and verified rather than planted. Three further candidates
were rejected on the same tests.
Verification
831 tests pass. Both recorded eval runs re-scored against the grown spec, with the
scorer controlled first each time: a run-state with every finding stripped must fail,
and does.
Full changelog: v0.10.0...v0.11.0
v0.10.0: a defect found four times, fixed once
Eight changes since v0.9.1. The two that matter most are a defect this project kept re-finding, and a gap in what a run records about itself.
One fix instead of a fourth patch
The same defect had been found four times: a rendered summary that reads as a clean result when the underlying question was never answered. #100 (not-applicable verdicts rendering as "0 findings"), #122 (a could-not-run domain rendering as a bare zero), #184 (the evidence boundary reading "0 of 16" when nobody was asked), and #195 (the self-assessment limits block).
Each previous fix landed only where the defect was found, which is why the third shipped inside the block written to prevent the second. #204 routes every summary that states a count over a population through one helper that must return distinct text for three states, none found, none recorded, and N found, and adds a check that fails the suite when a new summary block is added without being classified. Verified against the real legacy run-state #184 was found on: Evidence boundary: 0 of 16 becomes 16 of 16 completed domains never recorded what they did not read.
A run now records where its time went
The server stamped its own clock exactly twice, at begin_run and at render_report. #206 adds domain_rules_fetched_at and domain_recorded_at, so a run knows when each domain's rules were first served and when its result was accepted. These are arrival stamps and not durations, and the field contract says so: in a run that fans out to a subagent per domain the domains overlap, so the gap between two stamps is not any domain's elapsed time.
The rest
- #201:
AUDIT.mdnow asks the auditor to recordself_assessment. It never did, so the per-domain confidence column read "not reported" on every row while the README promised a finding from a shaky domain would not look identical to one from a solid one. A test now catches the next field added to the report but never requested by the protocol. - #203: a rule verdicted
findingwith no matchingFindingis now rejected, closing the direction the consistency check missed. Consent flags are derived fromTelemetryConsent.model_fieldsrather than a hand-maintained list, anddocs/feedback.mdnames all nine consented sections instead of four. - #200: the test suite no longer makes about 180 live
git ls-remotecalls to github.com, dropping it from 3 minutes 24 seconds to 55 seconds, and letting it run offline. - #202: the recorded cost figures carry the tool version they were measured at, the WCAG 2.2 AA claim states which criteria are actually machine-checked, and the
Closes #Npolicy carves out release and housekeeping pull requests. - #208: the reader-conclusions consent row no longer reads like a prompt for input the configuration page does not have.
- #209: the README screenshots are captured against this build.
Upgrading
Change the tag in your registration command and re-register. There is no schema_version bump, so run-state files written by earlier builds still load and re-render.
Two behaviours changed for a caller. record_domain_result now rejects a rule verdicted finding that carries no Finding, and AUDIT.md asks for a field it did not previously mention. Both are tightenings, and both fail loudly with an actionable message rather than silently.
v0.9.1: what the tool measured, and what it was told
A follow-on to v0.9.0 from the same tester run. Both changes are about a value the report printed with more authority than it had.
Upgrading: re-run your install command with @v0.9.1. No schema change; run-states are unaffected in both directions.
The header's asserted rows now say so (#176)
Assistant and Model come from the calling assistant and cannot be checked from here, yet they rendered exactly like the rows beside them that were measured. Two runs by the same tester both executed gpt-5.6-sol while their headers read gpt-5.6-luna and GPT-5, and the two headers were initially read as evidence of a model change.
That matters more than a label: #129 defined severity as assigned by the assistant named in the header rather than measured, so the model row is what every severity in the report hangs off, and it was the one provenance row with no qualifier.
Those rows now carry a short self-reported marker, with one footnote under the grid taking the reader from the model row to the severities that depend on it. Earlier contributors inherits it, and the environment block gets the same sentence. The values themselves are unchanged: unknown-but-stated is honest, and refusing unverifiable values would only lose the information.
Started and Finished are deliberately left unmarked. The Duration row already reconciles them against the server's own clock and says so, which is a stronger statement than the qualifier.
The protocol now asks for coverage (#181)
Every domain in that run reported no coverage reported, which read as an auditor that could not be bothered. It was not. AUDIT.md had never asked: the word appeared twice in the entire protocol document, once inside the finding-body rules and once in a list of report sections, and neither was an instruction. The field existed, the report rendered a column for it, and nothing ever requested it.
It is now asked for, and it stays optional on purpose. The count cannot be verified from the server, so requiring it would guarantee a number is produced whether or not anything was counted, which is a fabricated measurement wearing the same clothes as a real one. The instruction says to omit rather than estimate, a blank keeps meaning "not claimed" rather than "claimed as zero", and the Files column carries the same qualifier as the header rows above.
Verification
797 tests passing (791 in v0.9.0). The marker is tested in both directions: present on the asserted rows, and asserted absent on Repository, Commit, Tool version, Tool commit and Duration, so it cannot quietly spread to every row and stop distinguishing anything. ruff and mypy clean.
v0.9.0: a finding has to say why it applies
A protocol release. Two verdicts that could previously be published as defects in someone's repository without ever making the claim that would justify them now have to make it.
Upgrading: re-run your install command with @v0.9.0. Schema version 5. Run-states from every earlier release remain readable and re-renderable by this build; a v0.9.0 run-state cannot be read by an earlier build.
Both changes came out of an external tester's Codex run and the repository owner's review of it, which rejected 20 of 32 findings. Most of those rejections were correct, and correct in a way that made them the tool's defects rather than a disagreement.
A finding must state its precondition (#178)
not-applicable names the precondition that fails. could-not-evaluate says why a verdict could not be reached. pass is a specific claim that you looked. finding asked for nothing, so nothing in the protocol ever put the question "does this rule's precondition hold here at all?" to the auditor.
That run filed 11 findings in the requirements domain and 7 in threat modelling with no not-applicable verdict in either, while setting aside 147 rules elsewhere. Among them: no release SBOM, against a project with no release pipeline. No CVSS-scored vulnerability register, against a project with one dependency. No assistive-technology evaluation, against a personal tool whose only user is its author. Every rule real, every citation correct, every precondition absent.
Finding.precondition is now required, and names where the precondition holds in this repository. Being unable to write it is the signal, not an obstacle: the rejection message names not-applicable as the verdict that was actually correct.
AUDIT.md's not-applicable criteria gain the limb they were missing. A precondition can be a context, not only an artefact: external users, a release pipeline, a distribution channel, an organisation, a compliance regime. The old worked example only covered the artefact case.
A completed domain must record its evidence boundary (#179)
In the same run, the requirements domain returned could-not-evaluate on one rule with the note "the README points to external issue records that were not inspected", then filed 11 findings in that same domain asserting those requirements did not exist, each citing README.md. The requirements were in the project's issue tracker throughout. One rule knew. Nothing carried it to the other eleven, because there was nowhere to record it once for the domain.
DomainResult.uninspected_evidence is now required on a completed domain: the evidence stores the repository points at that the audit did not open. An explicit empty list is the common answer and a claim in its own right; never recording it is refused.
The report prints the list beside every finding in that domain, and the Tool performance summary gains an Evidence boundary block that separates three outcomes it used to collapse into one: domains that read everything the repository pointed at, domains that read less and said so, and domains that never answered.
The tool does not guess which findings are absence claims. That judgement needs evidence the tool does not have, and making it here would be the tool pre-writing the verdict. It shows the boundary and the finding together and lets the reader decide.
Also
- AUDIT.md now says to cite the line a finding is actually about. A reviewer of that run reconstructed four findings from their cited locations alone, guessed the claim from the code at that line, and refuted claims the audit had never made.
- The demo report carries both evidence-boundary states, so the new block is visible in
docs/demo/report.htmlrather than only its all-clear message.
Verification
791 tests passing (774 before, 17 new). ruff check, ruff format --check and mypy all clean. domains/pack.toml's requires_tool is deliberately unchanged: it pins the pack format, which this release does not touch, so existing rules packs work as they are.
v0.8.1: shaped by the first real reader
The first release whose contents came from watching a real reader use the tool: an external tester read a v0.8.0 report of a 181-rule run on his phone and sent screenshots, and everything user-facing here follows from that.
Upgrading: re-run your install command with @v0.8.1. No schema change: run-states from 0.8.0 remain readable, and this build reads them.
Reading the report on a phone
- The not-applicable and could-not-evaluate blocks collapse behind one-line summaries carrying their counts, instead of screens of rule ids between you and the rest of the report
- The per-domain table stacks into labelled rows at phone widths, instead of crushing six columns into a horizontal scroll
- Long values such as full commit hashes wrap inside the run-details card instead of escaping it
Verified against the tester's actual run re-rendered at 412px and 320px.
Provenance
- Pack dirty detection is now scoped to the rules directory. The tester's run lost its pack staleness check to an untracked
mcp.jsonat the clone root, created by this README's own headless example. A stray file outside the rules directory can no longer cost you the comparison; a change inside it still does, because that genuinely changes the pack - Editable and checkout installs of the tool now report their own git commit instead of unknown, a modified tool build carries a visible caveat, and a dirty build reports could-not-check rather than pretending to compare against releases
- The pack version string reads
v0.6.1+10instead ofv0.6.1+, so two runs' packs compare at a glance
Pack compatibility contract
The tool reads an optional pack.toml from the rules directory, in which a pack declares its rule-file format and the oldest tool it requires. A mismatch renders an honest notice naming both sides; absent metadata claims nothing. The maintained pack now ships this file. Third-party packs can adopt it with two lines.
Protocol
AUDIT.md now carries explicit criteria for declaring a whole domain could-not-run versus running it with per-rule not-applicable verdicts, with a bias to running when in doubt. Motivated by two audits of the same repository at the same commit reaching opposite calls on a whole domain, one of which found a high-severity problem the other never looked at.
v0.8.0: the report answers the reader's question first
This release is mostly about the report you actually read, plus six defects found by a code review of the whole range before cutting it.
Upgrading: re-run your install command with @v0.8.0. This is a minor rather than a patch because the run-state schema moved from 3 to 4: a v0.7.0 build refuses a run-state file written by this release.
The report was redesigned
It used to open with six screens about how the run went, and the first finding landed on printed page 7 of 26.
- A computed headline is now the first thing on the page, naming what needs attention first
- Findings are sorted worst first, instead of the order they happened to be recorded in
- Five separate per-domain lists are now one table, with inline bars encoding quantity as length
- The tool self-assessment is collapsed behind summaries that each carry their own numbers, so nothing is hidden and nothing occupies the top of the page
- Severity levels are defined in the report itself, and stated as assigned by the assistant rather than measured
- Each finding carries its domain confidence and rule-fetch status, and those travel into filed issues too
- Print drops the issue-filing section rather than clipping it, long paths wrap on narrow screens, and dark-mode severity badges now meet contrast
Honesty of the audit itself
not-applicablerequires a written reason and renders it. One earlier tester run had 172 of 260 verdicts as unjustified not-applicable, invisible in the output- Rule fetches are recorded, so a verdict reached without ever fetching its rule text is provable
- Run duration is measured by the server and compared against what the assistant claims
Gemini setup
The documented install could never have worked: the extension manifest sat in a subdirectory, and Gemini CLI resolves an extension only from a repository root, so the MCP server was never registered. The packaged extension is dropped in favour of a manual server entry and a prompt you can paste. Gemini support remains documented but unexercised against a real Gemini CLI.
Fixed before this cut
A pre-release review of v0.7.0..main found six defects, all fixed here:
- Resuming a run saved by v0.7.0 rewrote its schema version and made it permanently unresumable
- A finished timestamp in a different timezone form from the started one raised a
TypeErrorand destroyed a completed run at the final step - A typo in a custom output path (
~unknownuser/...) crashed the configuration page and lost the rest of the form - The markdown stripper rewrote shell commands, so a finding saying
rm -rf build/*could file an issue sayingrm -rf build/ - A finished time earlier than the started time rendered as its magnitude, so the duration row declared two identical figures disagreed
- The issue list over-counted how many boxes were ticked, ignoring findings already filed
Feedback
The report now asks two optional questions about what you concluded from it, alongside the existing freeform box. Both are off by default and never prefilled, like every other shared section.