Zavet is dira's knowledge sibling: dira answers "where did the time go?",
zavet answers "what did that time produce, and why?". The repo layer (the
.zavet/ directory format, decision records, guard hooks, slash commands)
lives in its own product — the zavet plugin.
This repo contributes the optional dira capability: guard-event ingress,
trailer + decision capture during the ordinary git poll, session attribution,
local storage, and the dira zavet query surface.
Each product works without the other. The plugin is fully functional with no dira installed (capture, recall, enforcement — just no time correlation); dira is inert for repos that are not zavet-active.
dira zavet install installs (or updates) the zavet Claude Code plugin by
shelling out to the claude CLI — it never hand-edits Claude Code's own
config. dira init writes .claude/settings.json directly, but that
precedent doesn't transfer here: plugin install is stateful (clone a
marketplace, checkout a version into the plugin cache, and update a
registry file that already carries its own schema version), and
hand-writing that state would declare a marketplace that was never actually
cloned and could race a live Claude Code session rewriting the same files.
dira zavet install install at user scope (the default)
dira zavet install --scope project or: project, local
dira zavet install --update already installed: refresh the
marketplace + plugin instead of a no-op
dira zavet install --dry-run print the exact `claude` invocations
without running them
State is always detected first, never assumed: claude plugin list --json
(matching id == "zavet@dirahq"), falling back to reading Claude Code's own
installed_plugins.json only when that fails, and only trusting that
fallback file when its top-level "version" field is exactly 2 — a
schema bump is a signal to stop trusting our own parsing, not to guess. A
repeat dira zavet install against an already-installed plugin is a
read-only no-op unless --update is passed. On success the command reports
the installed version, scope, and install path, plus an advisory skew
line comparing this dira build against the plugin's self-reported
min_dira (never a hard error — see below) and a reminder to restart
Claude Code to apply the change. dira zavet status prints the same
plugin line, so a stale install is visible from the everyday command, not
just from install itself.
If claude isn't on PATH, dira zavet install prints the manual
two-command recipe and exits non-zero rather than half-installing anything:
/plugin marketplace add dodi-smart/dirahq-zavet
/plugin install zavet@dirahq
dira zavet install depends on four things staying stable across both
repos. Changing any of these is a breaking change that needs a
coordinated release:
- Identity — marketplace
dirahq+ pluginzavetcompose to the idzavet@dirahq, the string bothclaude plugin list --jsonandinstalled_plugins.jsonkey on. - Source — repo slug
dodi-smart/dirahq-zavet, marketplace manifest at.claude-plugin/marketplace.json, default branchmain. A github marketplace source records noref, soclaude plugin marketplace addalways clones whatever the default branch currently is. - The version probe — an executable
bin/zavetat the plugin root supportingversionandversion --json, the latter emitting{"v":1,"plugin":"zavet","version":…,"emit_schema":1,"min_dira":…}. This is the only machine-readable compatibility signal; dira never gates on it — see "Compatibility posture" below — but it must keep parsing. - Guard-event schema v1 — the JSON object shape on stdin of
dira zavet emit, documented above under "The plugin ↔ dira interface".
Compatibility posture: surface skew, never gate on it. The skew line is
advisory only. An installed plugin build that predates the version
subcommand — or any other failure resolving it (binary missing, non-zero
exit, unparseable output) — degrades to an "unknown" skew line, never an
error: the whole product promise is that dira and zavet each work fully
without the other.
As of zavet 1.3.0 the plugin also writes a set of generated, COMMITTED
cross-harness adapter artifacts into a repo — .zavet/bin/zavet (a vendored
copy of the CLI), an AGENTS.md marker block, .grok/rules/zavet.md,
.grok/hooks/zavet.json, and .zavet/githooks/{commit-msg,pre-commit} — via
zavet adapters. Existing repos get none of it on upgrade, and the vendored
copy goes stale silently on every later plugin update. dira zavet install
folds that refresh in as a best-effort tail step, but only when every one
of these holds:
cwdresolves to a git toplevel (RepoGate::NotGitotherwise).- That toplevel carries a
.zavet/directory (RepoGate::NoZavetDirotherwise) — a repo that never adopted zavet has nothing for adapters to refresh. - The installed zavet (via
zavet version --json, parsed and compared as a release triple) is 1.3.0 or newer — 1.2.0 has noadapterssubcommand at all, and runningadapters --checkagainst it prints general usage/help text and exits 1, the SAME exit code 1.3.0 uses for "stale". Exit code alone cannot tell those apart, so this version guard runs beforeadapters --checkis ever invoked. zavet adapters --check(run pinned to the repo root, never dira's own cwd) reports the artifacts stale.
Steps 1–3 are deliberately checked by dira itself, before zavet is ever
spawned: zavet adapters --check from a non-git directory (e.g. $HOME)
prints "not inside a git repository", reports all six artifacts
missing/stale, and exits 1 — indistinguishable by exit code from genuine
staleness. Without dira's own gate, running dira zavet install (a
machine-scope command, valid from anywhere) from an unrelated directory
could otherwise write adapter files where none belong.
Every printed adapter line names the repo path it applies to, so a user who
ran --update from an unexpected checkout can see which tree was touched.
Pass --no-adapters to skip this refresh even when it would otherwise
apply:
dira zavet install --update --no-adapters refresh the plugin, skip adapters
dira zavet install --update --dry-run plans the adapters invocation too,
printed but never run:
[dry-run] <plugin-root>/bin/zavet adapters # in <repo>
Git hooks are never installed by dira. zavet hooks --check is run
alongside the adapters check purely to report githooks: active or
githooks: inactive — run \zavet hooks install` in ; dira never runs zavet hooks installitself, becausecore.hooksPath` is not zavet's alone
to own — Husky, lefthook, and plain pre-commit all set it too, and zavet
itself refuses to take it over.
The four-item stable contract above is unchanged. This is a
version-gated best-effort addition layered on top of it, not a fifth item —
an installed zavet older than 1.3.0 (or a repo with no .zavet/, or a
non-git cwd) simply sees a not checked/unknown line and nothing else
about dira zavet install changes.
| Layer | Mechanism | Precedence |
|---|---|---|
| Per-repo override | dira zavet enable|disable|reset (meta table) |
highest |
| Global knob | [modules] zavet = "auto" | "on" | "off" in config.toml, or DIRA_MODULES__ZAVET |
middle |
| Auto probe | in auto, active iff .zavet/ exists at the repo toplevel |
lowest |
A committed .zavet/ therefore lights zavet up for every dira user of a team
repo; individuals can still opt out per repo.
The plugin's hooks report guard activity by piping one JSON object to
dira zavet emit (stdin), guarded by command -v dira and run in the
background — fire-and-forget. The shim forwards it over the daemon's Unix
socket with a 500 ms budget and always exits 0; a missing daemon, an older
dira, or a malformed payload can never affect the hooks.
{
"v": 1,
"kind": "guard_shown | guard_blocked | guard_complied | guard_overridden | decision_superseded",
"decision_id": "D-0042",
"file_path": "src/auth/session.ts",
"cwd": "/abs/path/inside/repo",
"ts": "2026-07-15T12:00:00Z"
}Contract rules (daemon side):
v,kind,decision_id,cwdare required; everything else optional.- Unknown fields are ignored; unknown
kinds are stored verbatim (a plugin newer than the daemon degrades to "recorded, filtered at query time"). decision_idmust look likeD-<something>; otherwise the event is dropped.- A
tsthat is not RFC 3339 degrades to the daemon's receive time. - The daemon resolves the canonical repo from
cwdvia its own git resolver — it never trusts a caller-supplied repo identity. - Events for repos where zavet is inactive are dropped (debug-logged).
No filesystem watcher. The daemon's existing event-driven commit poll, when a repo is zavet-active, additionally captures inside the same 10 s blocking budget:
- Trailers — one batched
git log --no-walkover the walked shas; keysWhy: Rejected: Constraint: Refs: Supersedes: Spec:(case-insensitive), firstD-NNNNreference extracted; idempotent by(sha, seq). - Decision records —
.zavet/decisions/*.mdfiles added/modified by each walked commit: frontmatter (id,title,status,guards,supersedes,checks,corrected-by) plus body, upserted by(repo, id)with first-sight provenance (the introducing commit, its author date, and its attributed session are preserved forever);content_hashis the git blob oid. - Living specs —
.zavet/specs/<slug>.mdfiles (flat, dot-prefixed templates ignored; the filename stem is the identity), same first-sight upsert by(repo, slug). Frontmatter contract (shared with the plugin's parser, inline#comments allowed on structured lines, never ontitle):title,version,origin(designed|session|reverse-engineered),verified(true only after a human confirms spec matches code),confidence(low|med|high),date,paths(git pathspecs the spec covers),decisions(optional links),checks. Decision links are derived as the frontmatter list ∪ everyD-NNNNreference in the body, canonicalized — links live on the spec side only, decisions stay append-only.
Checks (checks: on either record type) bind an invariant to the command
that proves it, as label :: command — an item with no separator IS the
command. dira displays them and never runs them: a command read out of a repo
must only execute when a human explicitly asks, which is zavet verify's job
in the plugin. Nothing here detects, infers or special-cases a test framework;
the command is opaque, and exit 0 is the whole contract.
Corrections (corrected-by: on a decision) are the light sibling of
supersedes. Supersession replaces a record wholesale, which is too heavy when
one claim inside it turns out wrong; a corrected record stays active and
keeps its body, and every recall path leads with the correction instead. The
pointer may dangle — that is a finding for the plugin's zavet check, never a
parse error.
Staleness is never materialized: no table stores per-commit paths, so it
is computed at query time — git log <last_commit>..HEAD -- :(glob)<path>…
counts the commits that touched a spec's declared paths after its last
capture. The shellout runs in the repo directory the daemon last observed
(falling back to the caller's cwd); with neither, staleness reads unknown,
never guessed.
Framework rule sources follow linked spec frameworks with no extra step.
A spec-source: line for Spec Kit, OpenSpec or BMAD also brings that
framework's standing rules into agent context through the plugin: the Spec Kit
constitution, OpenSpec's config.yaml context:/rules: (and the legacy
project.md), BMAD's AGENTS.md block (and the legacy project-context.md).
The plugin owns them, marks them unverified and never edits them; one
rule-source: <kind> off line in .zavet/config turns one off. dira tracks
their metadata only (kind, path, blob oid, version, dates, rule count, state)
by reading zavet spec-sources --json from the installed plugin. It refreshes
when a commit touches .zavet/config, AGENTS.md or a rule file, and on
dira zavet reindex. dira zavet status lists them with a hint for any still
unratified or deprecated. The text is never stored and nothing about them is
synced (DIRASH-0040).
Attribution rule (everywhere): the unique active session for the repo
(SessionRegistry::session_for_repo) or NULL — never guessed. Unattributed
evidence still counts and is reported, so costs read as honest lower bounds.
dira zavet status— activation verdict (and why), capture health, a client-side plugin line (installed version, scope, enabled) resolved the same waydira zavet installdetects state — see "Install" above — and, when the installed zavet is 1.3.0+, read-onlyadapters:/githooks:lines from the same gate described in "Cross-harness adapter refresh" (never a write —statusonly ever checks). These lines are cwd-scoped and are suppressed entirely when--projectis passed: a named remote project has no necessary relationship to the directory the process is standing in, and reporting this cwd's adapter staleness against it would be misleading.dira zavet why <question, D-0042, or spec slug>— answer "why?" from recorded knowledge. A decision id or an exact spec slug answers directly; free text ("why are we polling instead of a filesystem watcher") ranks decisions AND specs across titles, slugs, guards/paths, bodies, linked decision ids, and trailer values — one confident hit (score ≥ 2× the runner-up across both pools) answers in full, several return ranked matches with excerpts. A decision answer carries the record, linked commits, guard-event history, and its covering specs; a spec answer carries the document, its origin/confidence/staleness badges, linked decisions, andSpec:-trailer commits. Both end in the cost panel: de-duplicated human seconds (same accounting asdira report), idle-trimmed agent seconds, and tokens per evidencing session, with totals.dira zavet wiki [topic]— browse the knowledge base: an attention line (uncaptured / off-branch / unverified / stale counts, shown only when something needs a human), decisions grouped by branch presence, the SPECS section (origin + confidence badges,⚠ stale · N commitsvs✓ current, path and decision counts), and the recent-trailer chronicle; with a topic, ranked matches. Sections cap at ten rows and point atdira zavet decisions— it is an overview, not the list.--jsonemits the view as one object.dira zavet decisions— captured decisions for the repo, one row each: id, title, guard count, guard activity, age.--guardsspells out the globs (wrapped under the title, never run off the edge),--branchnarrows to the checked-out branch,--jsonemits the view.dira zavet enable|disable|reset— the per-repo override.
The store keys knowledge by repo alone — decision ids are minted repo-wide, and records are append-only — so a decision recorded on another branch keeps listing forever. Both list views therefore report what the working tree says about each record, without ever removing a row:
| group | meaning | remedy |
|---|---|---|
ACTIVE / SUPERSEDED |
the record's file is in HEAD's tree |
— |
OFF BRANCH |
captured, but its file is not in this tree | none needed; it governs another branch |
UNCAPTURED · uncommitted |
on disk, not in HEAD |
commit it |
UNCAPTURED · awaiting sweep |
committed, but the daemon has not walked that commit yet | wait for the next sweep (30 s active) |
UNCAPTURED exists because capture reads git objects, never the working
tree (.zavet/config is the single exception). A record written by
/zavet:decide and not yet committed is invisible to every query, and
without this section it is invisible silently — which reads as dira having
lost it.
Presence needs a working directory to resolve. With --project <repo> from
somewhere else, or on a repo the daemon has never seen a session in, presence
is unknown and the groups collapse to one plain list — it is never
guessed, the same rule spec staleness follows.
Records with origin: reverse-engineered / verified: false (the
/zavet:backfill output) render as amber unverified — hypothesis badges
everywhere — recall never presents reconstructed rationale as fact.
Zavet data never rides AttestationBatch: the attestation wire stays
content-free by tested invariant (wire_contract_carries_no_content_fields).
M2 ships the separate KnowledgeEnvelope — same JCS/Ed25519 framing, its
own endpoint (POST /api/v1/knowledge), its own four sync cursors, and its
own double consent gate:
- Producer knob —
[sync] knowledge = "off" | "metadata" | "full"inconfig.toml(orDIRA_SYNC__KNOWLEDGE). Default off: zavet works fully on the machine, nothing leaves it.metadataships decision ids, slugs, titles, status, guard globs, spec paths, trailer keys + decision refs, guard events, shas (recordSha= the git blob oid), check LABELS,correctedBy, and per-repo coverage/capture counts — enough for dashboards to show structure, cost, and guard telemetry without any prose. The coverage surface is active decisions' guard globs ∪ every spec's paths:verifiedrecords a human review of whether a spec matches the code, which is a different question from whether the code is documented, and decisions have always counted while unverified (D-0017).fulladds the consent-gated content fields (bodyMd, trailervalue, and a check'scommand), which are pinned by an explicit path allowlist in the contract's no-content invariant. A check splits across that boundary deliberately: the label names an invariant the way a title names a record, while the command is a line of the repo's own build configuration and can name internal tooling, hosts and paths nobody agreed to publish by turning sync on. - Workspace tier (cloud-side) — the dashboard's ZAVET · KNOWLEDGE SYNC
setting:
offrefuses the channel (knowledge_disabled),metadatarefuses content (content_not_allowed— the daemon downgrades the window withstrip_content()and retries once, so sync never wedges),fullaccepts everything. Content is stored only when BOTH ends saidfull.
Cursors: decisions and specs advance on a touched_seq watermark (bumped on
every upsert — git author dates are non-monotonic under rebase, so
updated_at can't be a cursor), trailers on rowid, guard events on their
ULID. All four blank together with the attestation cursors on a dataEpoch
change, dira nuke, or dira device resync — and because ingestion is
idempotent by natural keys + a deterministic batchId, wipe-and-resync
reproduces cloud state exactly.