An integrity hook for Claude Code that watches the agent's own tool calls and blocks the ones that fake a check. When Claude says it did something (ran the tests, cited a paper, committed the fix, verified the certificate), makoto holds that word against its record. If the deed isn't there, or the verification was quietly disabled, makoto blocks the tool call (or the end-of-turn) and hands the agent a one-line correction to retry against.
It judges the agent against its own utterances and record, never the world's truth. It holds no facts ("France doesn't exist to it"); it only checks that a claimed word is kept, whole, and honored in deed. A word it lets through becomes spendable: trustworthy tender a reviewer or another agent can accept without re-deriving it.
makoto fires on mechanical hook events: every PreToolUse
, PostToolUse
, Stop
, and (advisory-only;
see ConfigChange watch below) ConfigChange
.
It blocks (exit 2, which Claude Code treats as block-and-retry) on 22 pre-checks across five
families and 14 end-of-turn gates (plus gate.stale_establisher
and gate.undeclared_falsifiable
,
two permanently-advisory catalog-completeness checks outside this 14-gate contract entirely: 16 live
Stop-tier checks in total). Every pre-check and every one of the 14 end-of-turn gates but two blocks;
there is no silent "warning" tier for those (see Fire level). The two documented
exceptions are gate.self_wired
and gate.canon_fingerprints_advisory
(below), advisory-only checks that by design never block.
Verifier weakening: a check silently neutered
content.verifier_predicate_weakened
loose-comparator verifier (startswith
/endswith
/re.match
where==
is meant)content.verifier_exit_masking
exit-code masking (|| true
,; true
,set +e
on a test/build/lint)content.verifier_body_hollowed
hollowed verifier body (return True
/pass
in a constitution check)content.env_gated_audit
audit/verification code gated behind an env var ·content.integrity_suppression_flag
integrity-named suppression flag (*_skip = true
)
Fabricated evidence: a claim with no backing artifact
content.phantom_citation
phantom citation (Author-Year not inmakoto/docs/CITATIONS.md
)content.unsourced_webfetch
WebFetch of a URL never seen in any prior tool result this sessioncontent.fabricated_commit_sha
fabricated commit SHA/tag presented as proof of a commitcontent.deferred_checkbox_theater
DEFERRED
-style checkbox theater on an open to-do itemcontent.illusory_authorship_trailer
an illusory AI-authorship commit trailer the agent never actually wrote
Security-verifier disabling: each a named CWE
content.cert_verify_disabled
/content.cert_none_mode
/content.cert_reqs_none
TLS / peer-certificate verification disabled (verify=False
,CERT_NONE
)content.jwt_signature_disabled
/content.jwt_none_alg
JWT signature verification off /none
algorithm whitelistedcontent.paramiko_host_key_weakened
paramiko SSH host-key check weakened toAutoAddPolicy
·content.timing_unsafe_compare
timing-unsafe==
on a secret/HMAC/digest
Self-defense
content.self_mute_guard
makoto self-mute (disabling or un-wiring makoto viasettings.json
)
Scope & contract discipline: illusory progress and out-of-contract action (SPEC-5, ported by shape from Assay)
event.thrash_revert
a whole-file Write that reverts a file to an earlier byte-identical content after an intervening different Write (A→B→A, no net progress)event.forbidden_location
a Write/Edit whose target resolves outside the working directory or into a protected system/credential locationgate.contract_order
a result-producing call issued while a declared Plan's dependency for that step is still undischarged (its Stop-time sibling gate guards the remainder at turn end)
End-of-turn gates: fire on the agent's closing claims, checked against the recorded ledger:
gate.completion
: "done / createdX
" but the artifact isn't on diskgate.advance
: advancing a phase whose precondition isn't recorded as metgate.green_claim
: "suite green" against a recorded test failuregate.dropped
: a forward promise carrying identifying info ("I'll adddef X
toY
", "3 tests inZ
") left undischarged at turn-end, said-but-not-done, checked against the agent's own touched-keysgate.fabricated_action
: "I ranX
/ executedY
" in a turn with no tool call at all (presence-of-work, not command-text match)gate.named_test
: "test_foo
passes" against a recordedFAILED
of that exact named test (coreference-pinned; the green_claim delta)gate.stale_pass
: "all tests pass" against pytest's ownlastfailed
record still naming a live failing test (the runner's ledger, existence-filtered for staleness)gate.liveness
: fires on the code itself, not a claim: a statement with no live effect inside a closed function (dead/illusory work the present-closure model can prove inert). It walks the turn's touched.py
ASTs, so it yields a finding per illusory statement rather than one per turn.gate.self_wired
:advisory only, never blocks(seeFire level). Partial-strip detection of makoto's own.claude/settings.json
hook wiring: fires ifPreToolUse
/PostToolUse
/Stop
is missing a makoto-dispatching entry while at least one other still has one. It has a documented blind spot: a single edit that strips all three simultaneously disables this check in the same instant it would have fired (Claude Code reloads hook config live, not once at session start), so it provides zero coverage against that full-strip case. See theConfigChange watchsection below, which closes exactly this gap when wired.gate.hollow_test
: fires on the code itself, not a claim: a HOLLOWED test (SPIRIT.md §4), one that survives in name while its content is gutted, so it can never actually fail. Four sub-patterns: no assertion of any kind in the test body; an asserted tautology (assert True
, or comparing an expression to itself); a broad, no-optry
/except
that silently swallows the only call-under-test's failure; and a test-shaped function that can never fire independently, either nested inside another function (pytest's collector never discovers it) or gated behind askipif
/skipIf
condition that is provably always true.gate.canon
:blocking(level="error"
, unlike the advisory exceptions below). Two language-agnostic Stop primitives over the turn's own call stream, reading only closed protocol fields (tool_name/tool_input identity,tool_response.interrupted
/.error
), no test-runner regex, no language token.canon.timeout
fires when the turn's LAST tool call ended in a direct error state (interrupted, or a self-emitted error code) and closed without resurfacing or resolving it; an error resolved by a later successful call stays silent.canon.recur
fires when the SAME tool call (identical name + byte-identical input) was re-issued back-to-back and every call in that consecutive run ended in the same direct error state: a stuck retry loop with nothing changed between attempts; a later success in the same run silences it. Certified via held-out adversarial RED fixtures (planted cases that must fire) plus a near-vacuous corpus-FP check: the honest corpus almost never carries the triggering precondition (interrupted
/self_error_code
) at all, so a clean corpus replay alone is inconclusive, not a certification (seeANCESTRY.md
§2 Part B for the corrected framing).gate.canon_fingerprints
:blocking. 4 of 17 ported session-level "canon" fingerprints (SPEC-5 Task 9, fromREF-lever-graded-primitives/signalminer/grade_planted.py
's 27-fingerprintTHE_CANON
) that are robust-core, blocking-capable by construction: 0-FP on both the planted-clean and real-Claude-gold negative sets per the gold-oracle certification (nogreen_checkdisabled
,nosrc_destruct
,nosrc_green_timeout
,notestedit_destruct
).gate.canon_fingerprints_advisory
:advisory only, never blocks. The other 13 of the same 17 fingerprints, each either resting on a soft/claim atom (claimed_pass_no_run
,tool_timeout
,assertion_weakened
) the gold-oracle finding doesn't certify as robust, or one of that finding's explicitly-named worst-disqualified fingerprints. Recorded to the audit log per SPEC-5's total-retention rule, never emitted as a block decision.gate.contract_order
:blocking. The Stop-time remainder guard over a declared Plan (SPEC-5, ported by shape from Assay'sContractOrder
pattern); fires when the turn ends with the plan's dependency remainder still non-empty. Its PreToolUse sibling guard is a pre-check, not a Stop gate; seemakoto/checks/contractOrder.py
.gate.stale_establisher
:advisory only, never blocks, opt-in. A declared Plan node recorded DONE whose named artifact no longer exists on disk (the one filesystem read this check family makes, ported by shape from Assay'sstale_establisher
). A product decision to escalate this to blocking is left to the caller; it is not made here.gate.undeclared_falsifiable
:advisory only, never blocks. A completeness audit over thechecks/
catalog itself: an orphan module the can't discover, or a declared id with no corresponding live module. A maintenance signal about makoto's own catalog, never a live integrity finding about the agent's turn.
Inspect the live catalog with makoto pattern list
; see one pattern in full with makoto pattern show content.phantom_citation
.
gate.canon_fingerprints
(and canon.timeout
within gate.canon
) read the session's own recorded call stream. Once a fingerprint's atoms go true they stay true forever, so without a real discharge path a single sanctioned action (e.g. an owner-approved destructive command) would otherwise block every remaining Stop for the rest of the session. The only discharge is an operator-attributed release, re-derived from the host-written transcript at check time and never trusted from ledger content, so no tool call or file write can forge it. Say, as a real message in the conversation (never inside a tool call or file write):
makoto release.operator <fingerprint-id>: <your reason>
makoto verifies the turn is genuinely user-authored, non-synthetic, and
timestamped after the finding first fired, then discharges that exact fingerprint for the rest of the
session. The discharge is chain-appended (kind="release.operator"
) for the audit trail; the block decision itself is always re-derived from the transcript, never read back from that row.
Annotate the line with makoto-allow: <reason>
(any comment style, case-insensitive). makoto won't fire on it, and your rationale is on the record: an auditable note, not a silent bypass.
if os.environ.get("ENABLE_AUDIT_TRAIL"): # makoto-allow: app feature, gates user-facing audit logging
write_audit_trail()
**See **: 誠 (makoto), the constitution every pattern derives from: a word is real the way water is wet, a constitutive property, not an after-the-fact audit.
docs/SPIRIT.md
/plugin install https://github.com/Clear-Sights/Makoto
Claude Code reads hooks/hooks.json
and registers PreToolUse
, PostToolUse
, and Stop
hooks
pointing at ${CLAUDE_PLUGIN_ROOT}/_dispatch_shim.sh
automatically (which exec
s
python -m makoto._dispatch
). ~/.claude/settings.json
is NOT modified; the plugin system manages its own hook registry.
State dir + makoto.db
are created lazily on the first hook invocation.
An illusory AI-authorship commit trailer reaches a commit through two doors. Pre-Check content.illusory_authorship_trailer
blocks
the agent-authored one: the trailer typed into a git commit
message or into file content, the
surface no setting can reach. The other door is Claude Code's own automatic append, which a
setting governs. To close it at the source, set in ~/.claude/settings.json
:
{ "includeCoAuthoredBy": false }
This is defense in depth, not a replacement: the setting closes the auto-append door, content.illusory_authorship_trailer
closes
the agent-authored one. makoto's install does not write this for you; it leaves settings.json
untouched beyond hook wiring (above); set it yourself if you want the earlier layer.
If you previously ran the old python -m makoto install
(0.3.0 or earlier), your
~/.claude/settings.json
has makoto-managed hook entries. Running the plugin alongside would cause double-dispatch. Migrate cleanly:
python -m makoto uninstall # removes old settings.json entries
/plugin install https://github.com/Clear-Sights/Makoto # installs the plugin
pip install -e /path/to/makoto
The state dir and makoto.db
are created lazily on the first hook invocation; there is no separate init step.
/plugin uninstall makoto
python -m makoto uninstall # removes makoto-managed settings.json entries
The state dir (~/.claude/makoto_state/
) is preserved on uninstall: audit.jsonl
and makoto.db
remain for forensic value. To fully reset, rm -rf
the dir.
python -m makoto status # patterns loaded, hooks wired, state dir, any patterns muted
python -m makoto pattern list # the full live catalog as a table
python -m makoto pattern show content.phantom_citation # one pattern in detail
python -m makoto show src/auth.py # ledger state for a normalized location key
python -m makoto install # legacy: wire settings.json (prefer the plugin)
python -m makoto uninstall # remove makoto-managed settings.json entries
If you want to inspect or hand-wire what the plugin does, add to the hooks.PreToolUse
,
hooks.PostToolUse
, and hooks.Stop
arrays of ~/.claude/settings.json
:
{
"matcher": "*",
"hooks": [{"type": "command", "command": "python -m makoto._dispatch"}]
}
Bracket the additions with # makoto-managed-begin
/ # makoto-managed-end
markers for idempotent removal.
| Code | Meaning |
|---|---|
| 0 | No error finding. The tool call proceeds normally. |
| 2 | At least one error-level finding. Claude Code interprets exit 2 as block-and-retry: the tool call (or the turn, for a Stop gate) is blocked, the stderr diagnostic is surfaced to the agent as a tool-error message, and the agent retries with that feedback in context. |
Every live pattern blocks (fire_level = "error"
→ exit 2). makoto deliberately has no
non-blocking tier: a warning
/disabled
resting state (witnessing a violation and letting the
tool through) is itself an illusory word, the exact weakening shape makoto exists to catch. The
earlier three-tier system was removed in the 2026-06-02 warning-tier-elimination (a pattern either
blocks at proven zero corpus-FP, or it is cut). This still governs all 22 pre-checks (_ALLOWED_FIRE_LEVELS = {"error"}
, enforced at load) and 12 of the 14 end-of-turn gates.
Two narrow, explicitly-recorded exceptions: gate.self_wired
(2026-07-05) and
gate.canon_fingerprints_advisory
(SPEC-5 Task 9, DESIGN DECISION 26) fire at level="advisory"
,
not "error"
, so each is recorded to the audit log but never emitted as a block decision. Neither
is a reintroduction of the cut warning
tier. gate.self_wired
is a single, named check whose
entire subject is makoto's own hook wiring, shipped advisory-only by explicit DESIGN DECISION as
partial-strip detection, not prevention (it cannot see, and does not claim to see, a simultaneous
full strip of all three hook entries; see the ConfigChange watch section, which closes exactly this gap when wired). gate.canon_fingerprints_advisory
covers 13 ported canon fingerprints that rest on a soft/claim atom or are explicitly disqualified against real-Claude gold, kept in the catalog at non-blocking advisory per SPEC-5's total-retention rule rather than dropped. Every other check keeps the invariant above unconditionally.
Each pattern carries a one-line, imperative retry_hint
telling the agent what to do instead. When a finding fires, the hint is printed on a second stderr line after the diagnostic:
[makoto ERROR] row content.verifier_predicate_weakened (verifier predicate weakened — loose-comparator shape): matched 'startswith(' at line 231
retry: Use '==' for status comparison, not '.startswith()' / '.endswith()' / 're.match'. ...
Every dispatch appends one structured JSON line to $MAKOTO_STATE_DIR/audit.jsonl
(default
~/.claude/makoto_state/audit.jsonl
). It captures enough to triage true-positive vs. false-positive
without leaking whole-file contents. It's plain JSONL; query it with jq
or any tool.
| Field | Description |
|---|---|
ts |
|
| ISO-8601 UTC timestamp, microsecond precision | |
event |
|
live.pre_tool_use |
live.stop (the firing events; PostToolUse is consumed for history) |
hook_kind |
|
| Raw hook name from the harness | |
tool_name |
|
The tool the agent invoked (Write , Bash , …) |
|
session_id |
|
| Opaque session token | |
project_root |
|
| Absolute project root at invocation time | |
pattern_fires |
|
List of pattern IDs that fired; [] if clean |
|
exit_code |
|
0 (clean) |
2 (finding emitted, block-and-retry) |
retry_hint_emitted |
|
Boolean, at least one fired pattern had a non-empty retry_hint |
|
findings |
|
Per-finding {pattern_id, level, file, line, snippet} |
Audit writes are best-effort. If the append fails (disk full, permission denied), dispatch prints one stderr line and continues with its original exit code. The audit subsystem cannot cause makoto to mis-block or mis-allow a tool call: a fundamental separation-of-concerns invariant.
Separate from the 22 pre-checks and 16 end-of-turn checks above: an optional ConfigChange
hook
entry (_dispatch_configchange.py
) watches .claude/settings.json
edits for makoto's own hooks being stripped. Two tiers, both fail-open on any unexpected fault:
Advisory(unconditional): a settings edit that looks stripped, but with no evidence this exact path was ever genuinely wired, logs a stderr line + an audit row (gate.configchange_advisory
) and never blocks. The ambiguous "never wired vs. just stripped" case the underlying verdict predicate cannot resolve on its own.Blocking(gate.configchange_transition
): fires ONLY on a genuinely evidenced transition, either this exact settings path is in makoto's own install manifest (configchange_manifest.json
, written bypython -m makoto install
), or a PRIOR evaluation of this same path observed makoto's hooks present (configchange_snapshots.json
). A path with neither piece of evidence never blocks, no matter how many times it evaluates as stripped: a project that never had makoto's hooks wired must never be blocked from editing its own settings.
Never fires for policy_settings
(organization-managed policy is out of scope). Not yet part of the
plugin-install path; wire it manually, the same way as the manual wiring
below, via a ConfigChange
hook entry pointing at _dispatch_configchange.py
.
Makoto blocks the illusory word, but until this session's work, it never issued tender for the
KEPT one. Here is the whole chain for one small, real, synthetic session
(docs/demo/
; regenerate instructions there):
WORD: the agent writessrc/auth.py
, then claims"test_login passes now."
at Stop.DEED: the write lands (kind="touched"
); a test run fails (kind="testrun"
,FAILED tests/test_auth.py::test_login
); a fix lands; a second test run passes (kind="testrun"
,PASSED tests/test_auth.py::test_login
): three tamper-evident, hash-chained rows, each linked to the one before it.RECORD: the test-delta redirect (Task 3) fires on the pass/fail flip and is ITSELF chain-appended (kind="audit"
); the redirect's own firing is part of the permanent record, not just a line on someone's terminal.RECEIPT:makoto receipt --session demo-session-001
reports 2 claims, both trace-bound to averify_chain
-checkable row, 0 exemptions:
{
"session_id": "demo-session-001",
"verified_through": null,
"claim_count": 2,
"trace_bound_count": 2,
"exemption_count": 0
}
The claim "test_login passes now."
is never re-derived by a human or a reviewer; it cites two
specific rows anyone can independently re-verify with verify_chain
. That is the whole pitch: chained, receipted claims, not a linter that yells and leaves no trace.
docs/demo/render_demo.py
drives 3 REAL scenarios through the actual dispatchers (not the frozen corpus above) against a fresh, throwaway state dir each, and captures the genuine stdout/stderr:
Each SVG is rendered directly from that scenario's real logged stdout/stderr, not hand-written.
Regenerate: python docs/demo/render_demo.py && python docs/demo/render_svg.py
(the latter needs
humanize
, pip install humanize
, for demo-only friendlier byte counts; never a core-package dependency, see that script's own docstring).