You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Sidegraph 0.14.0 preserves anchors and domains across Graphify 0.9.90, fixes store
setup and policy selection, and adds OpenCode V2 plus experimental Muse packaging.
Before updating an existing store, follow the migration guide:
update every Sidegraph client, back up the store, snapshot domains, then upgrade Graphify.
The RC tag stays immutable; RC installations keep their explicit RC source until you
return to the stable installation/profile. See the installation and update commands.
Claude Code and Codex pass the full native gate on Windows x64 and macOS ARM with
Python 3.13/3.14, including Windows PowerShell 5.1 without PowerShell 7. Muse remains
experimental MCP/shared skills; OpenCode V2 supplies MCP/shared skills. Their existing
macOS and owner field receipts do not establish Windows lifecycle parity.
Anchors to files with the same basename keep resolving after the engine upgrade.
External names resolve when Graphify emits several identical stub nodes.
Re-importing rationale does not duplicate compatible labels shortened by a newer engine.
sidegraph-domains snapshot preserves domain membership across engine regrouping.
A store can require a minimum Sidegraph version before clients read or write it.
Graphify leaves installed agent skills alone when Sidegraph runs it.
Changed
OpenCode V2 can install Sidegraph as a native Git package with shared skills and MCP
tools. Existing skill IDs and explicit MCP server settings take precedence. See the OpenCode installation guide.
Muse has an experimental native plugin with shared skills and MCP tools. Launch Muse
from the intended project directory; lifecycle hooks are deferred. See the Muse installation guide.
A graph community that holds code and documents is split in two before domains are
computed. Graphify 0.9.62 and later link documents to the code they mention, which merged
them.
Domain membership is computed by each sync and kept per node. A path prefix now adds its
files at sync time, not when a domain is read. A decision anchored under a path rule that
the 20% cap kept off the communities now reaches that domain too.
A domain whose refresh fails keeps its previous membership whole and is reported again by
every later sync until its cause is fixed; --force retries it.
A domain-membership pass that cannot run because another process holds the store is
reported once, as domain membership not refreshed: the store's writer was busy, and the
next sync runs it; --check does not count it. A pass that fails as a whole is reported
as * (every accepted domain) until its cause is fixed.
The supersede_domain answer carries snapshot_note when the domain's membership is
frozen.
Fixed
SessionStart and Stop no longer create an absent store. Missing host-relative stores are
selected at the Git root; MCP first use requires explicit setup for nested or non-Git
destinations. Init distinguishes an empty scaffold from a store with authored content
and still prints setup guidance.
The MCP server applies the committed ratification policy from the selected store's
project when no policy is set in its environment, including cross-project and symlinked
stores. Missing settings keep the manual default.
Doctor excludes committed file deletions from code drift while binding statuses are
uncomputed, and qualifies remaining changes as having unknown binding status. Projects
without a graph still report changes to surviving anchored files.
Hot hooks withhold records and memory counts on a new store or after an index reload until
sync computes binding statuses, so placeholder live rows cannot revive orphaned anchors.
Community names from newer Graphify builds are read from curated labels, pending hub names,
then node names. Hub labels use the file's basename within their own community.
Graphify's post-merge hook marker is recognised alongside its other refresh-hook markers.
SessionStart and doctor advise when a graph's engine stamp is outside the tested releases.
The advisory adds no human notice; doctor's --check exits 2 when it is present.
sidegraph-import recognises compatible re-cut rationale labels by their exact file/node
context as well as the existing title/origin key, reducing duplicate imports after a
Graphify upgrade or rollback.
Every Graphify run Sidegraph starts or tells you to start now sets GRAPHIFY_NO_AUTO_REFRESH=1.
Graphify 0.9.87 and later refresh the agent skills they installed (such as ~/.claude/skills/graphify/) on the first run after a Graphify upgrade unless that variable is
set. The graph refresh hook's helper, the CI recipes, the copyable commands in the docs and the
setup skill set it, and the rebuild instructions Sidegraph prints name it. Graphify 0.9.6 does
not read the variable.
Anchors to files that share a basename keep resolving on newer Graphify. Graphify 0.9.87 and
later label such a file by a path suffix (sidegraph/cli.py) instead of its basename; Sidegraph
reads those nodes as basenames again, so an anchor cli.py in src/sidegraph/cli.py still
finds its node, and the engine's own label still resolves too.
A path-named anchor (src/sidegraph/store.py with its own file_path) now resolves to that
file's node on any Graphify version, and is stored under the basename. Entities an earlier
version named by the full path heal at the one-time resync that follows the upgrade.
find_entity reads the spelling twins of one file (an entity named by the file's path beside
one named by its basename) as one entity, and get_entity_history lists the records bound to
any twin. New anchors, lookups and duplicate detection compare names the same way.
An anchor file_path given as an absolute path under the repository, with backslashes on
Windows, or with a leading ./ is stored repo-relative with forward slashes and binds live
instead of orphaning, through the MCP write tools and through capture. When a store sits in a
subdirectory of its repository, the root whose path the graph holds is the one used.
An anchor naming an imported external type (BaseModel, Path) now binds even when Graphify
emits one stub node per importing file. The stubs count as one match when none has a file and
they share a label and a node type, and the anchor gets a group binding only when they share a
community. This applies on every Graphify version. A type and a module of one name (FastMCP
and fastmcp) and repository code are still told apart. Document mentions of such names do
not gain a precise leaf binding, and reading surfaces keep their earlier resolution answers.
Sync reports an anchor whose file disappeared and whose name now matches only external stubs
as orphaned. A resolved external anchor without a community no longer prevents other anchors'
previous community bindings from being retired. A formerly shared group also retires when
the stubs split. Ambiguous anchors and failed rebinds keep those bindings when their current
community is unknown.
Added
sidegraph-domains snapshot freezes every accepted domain's membership into .sidegraph/domain-snapshot.json, so a Graphify upgrade that regroups communities does not
move domains. It raises the store's minimum Sidegraph version to 0.14.0. --build-with VERSION takes the snapshot from HEAD rebuilt with an older Graphify; --refresh
re-freezes it; --dry-run prints the counts it would freeze and leaves committed files unchanged.
sidegraph-verify reports bad-domain-snapshot and snapshot-without-requirement; sidegraph-doctor reports snapshot-split; sidegraph-sync --json and sync_anchors
carry snapshot_splits.
sidegraph-require --sidegraph X.Y.Z makes a store require at least that Sidegraph release:
the committed marker is raised atomically and never lowered. New clients check it before
using the store; verification reports malformed requirements and requirements above its version.
sidegraph-doctor prints informational spelling twins lines for an entity named by a path of
its file beside one named by the basename. They are not findings and do not change the exit
code. duplicate-entity no longer reports such a pair.
A new advisory check, refresh-helper-outdated, reports a graph refresh hook whose helper runs
Graphify without GRAPHIFY_NO_AUTO_REFRESH=1 (the helper an earlier sidegraph-init wrote). It
shows as a SessionStart line and notice and as a sidegraph-doctor finding, and sidegraph-doctor --check exits 2 on it. Run sidegraph-init with no flag: it rewrites the
helper and keeps your recorded hook choice. The helper lives in .git/hooks, so each clone
runs it once.
Opt-in Git candidate for testing real host installation and memory workflows. This is a
GitHub prerelease, not the stable release and not uploaded to PyPI/npm. Package metadata
is 0.14.0; the immutable candidate identity is Git tag rc/0.14.0-1. Stable main stays
on 0.13.0. Final Windows/macOS native acceptance is still in progress.
The main changes preserve anchors and domain membership across the Graphify 0.9.90 upgrade,
fix store-setup/ratification edge cases, and add native Git packaging for OpenCode V2 plus
experimental Muse MCP/shared skills. Use the RC-specific commands below; the README describes the normal stable installation.
Use a separate host profile and test repository for
this candidate; the sidegraph@sidegraph identity is shared with stable. An existing
marketplace registered to main must not be assumed to switch refs on a repeat add.
Existing stores: follow the 0.14 migration guide
before upgrading Graphify. Update every Sidegraph client first. Back up the store and
follow that guide's domain snapshot steps rather than upgrading Graphify first.
Claude Code
In a fresh test profile, run in your terminal:
claude plugin marketplace add 'https://github.com/SantyagoSeaman/sidegraph.git#rc/0.14.0-1'
claude plugin install sidegraph@sidegraph
Review installation/approval prompts and start a new session in your test project.
Tagged Git marketplace syntax: official command reference.
Start a new interactive session in the test project. Review and approve Sidegraph hooks
through the startup warning or /hooks. Installation alone does not grant hook trust.
OpenCode V2
Requires OpenCode V2 (baseline 2.0.29), uv and Git. In a fresh test profile:
Restart OpenCode in the test project, keep normal MCP/skill permissions and use @sidegraph-setup. MCP and skills only; no automatic session map/read injection/Stop nudge.
Muse (experimental)
Basic MCP/shared skills only; expanded hooks/workspace/Windows acceptance are deferred.
From a stable directory outside your project:
Start a new Muse process from the intended test project; restart when switching projects.
Basic native evidence covers Muse 1.4.7/macOS; no new Muse parity claim.
Ask the agent to save a real gotcha anchored to a real project file, then in a fresh
session ask get_task_context for that file and confirm it recalls the gotcha. OpenCode
and Muse need these explicit MCP requests. Record install errors and actual host/runtime
versions when reporting a candidate issue; remove credentials and private content.
The tag is immutable: a correction gets a new RC tag rather than changing this one.
To return to stable, leave the test profile and use your unchanged normal profile. Stable
plugin caches and the optional PATH CLI have separate update commands in the README.
On Windows, hooks misread the payload the host sends when a path in it has a non-ASCII
character (an accented or Cyrillic user name or project folder). The Stop capture reminder
never fired, PreToolUse delivered no records, and for some names SessionStart and
SubagentStart printed nothing.
On Windows, a non-ASCII git user name, branch name or repository path was read back garbled:
a ratified record could carry a garbled name in its provenance, and sidegraph-verify --against failed or reported false changes for records containing non-ASCII text.
A change to a file whose name has non-ASCII characters, spaces or quotes was missed by the
drift check and the commit-trailer candidates, because git escaped the name.
On Windows, a Codex hook started from a subdirectory of a repository whose path has non-ASCII
characters now runs from the repository root.
A default Git for Windows install no longer breaks the MCP server. Its installer puts only Git\cmd on PATH, which has no sh, so the server, which started with sh, did not start.
The Codex hooks have a Windows command for the same reason (see Changed).
The project's ratify policy now applies under Codex. sidegraph-init commits it to .claude/settings.json, which Codex does not read, so under Codex every agent proposal
waited for review whatever the team chose. The plugin's MCP server now reads the committed
value when its environment sets none; an explicit SIDEGRAPH_RATIFY_POLICY still wins.
Changed
The plugins launch without sh on PATH. Both MCP launchers start uvx (uv in the
development manifest) directly, and sidegraph-mcp --repo-root anchors the server to the
project: CLAUDE_PROJECT_DIR, else the git top-level, else the working directory. The Codex
hooks gain a PowerShell commandWindows that Windows PowerShell 5.1 parses. On native Windows
you need no PATH change and no PowerShell 7; Git for Windows at its default options is
enough. The Codex hooks need a Codex release that supports commandWindows (verified with
0.162.1). The Windows hook definition changes, so approve the changed hooks once in /hooks
on Windows; other platforms are unaffected.
The optional terminal CLI now installs from the same place as the plugin, the public
repository's main branch: uv tool install git+https://github.com/SantyagoSeaman/sidegraph.git@main,
updated after each release with uv tool upgrade sidegraph. The docs no longer send you to
PyPI for it; PyPI still carries every release for pinning an exact version. The path-skew
SessionStart line now offers uv tool install --reinstall git+https://github.com/SantyagoSeaman/sidegraph.git@main,
which moves an older install from PyPI, pinned or not, to the latest release.
README: the Quickstart is one numbered sequence with the plugin commands inline, a teammate
path, and the rarer options below it; "How it works" is a numbered list instead of a diagram.
The Codex plugin's SessionStart hook now starts on native Windows. Its command used POSIX
syntax (VAR=value command, $(...)) that the PowerShell Codex runs hooks in cannot parse, so
Windows sessions showed "the SessionStart hook could not start" although the entry point itself
worked when run directly. It is now wrapped in sh -c, like the Stop and SubagentStart
hooks, and keeps the same message if the launch fails. Codex records trust per hook definition,
so approve the changed SessionStart hook once in /hooks. On Windows the Codex hooks need
PowerShell 7: Codex runs hooks in PowerShell, and Windows PowerShell 5.1 cannot run them.
On native Windows, sidegraph-import now imports the documents a profile covers: their
repo-relative paths were built with backslashes, so every in-repo document was counted as
outside the profile and nothing was imported. Record keys, anchor lookups and provenance.ref
now use the POSIX path Graphify's source_file carries, so a later re-import on another
platform does not duplicate records going forward, and a symlink that resolves outside the
repository is refused there as on other platforms. A document on another drive than the
repository no longer crashes the import. A store that already holds backslash-keyed records
from an earlier Windows --any-doc import gets a second, POSIX-keyed record for the same
document on re-import; supersede the old ones.
On native Windows, records are now delivered before a Read, Edit, Write or Grep, and before a
Bash cat, sed, grep or rg, and a subagent brief's files find their records. These paths
were built with backslashes (src\sample.py) and never matched the POSIX paths anchors use.
Touch events and the usage statistics now record POSIX paths, and query_decisions(files=…)
without a graph finds the file's records. A root-level file whose name starts with .. is no
longer mistaken for a path outside the repository. Hooks now key a Windows session by its
transcript file name, as on other platforms, instead of the whole C:\… path, so hook showings,
touch events and capture state join the session the MCP server attributes calls to.
The 40,000-character hard cap on get_task_context and query_decisions now keeps its advice
blocks (how the seeds were read, what is not in the graph, the supersede hint) when a record's
text contains a ## line or a seed path is absurdly long, and the closing note names what was
cut. Seed paths echoed in those blocks are clipped to their head and file name. The usage
statistics count only the records left in the text after the cap, nearest-anchored records
included, and a worktree seed written as an absolute or nested path is journaled as the
repo-relative path the answer shows. A record spanning several lines is cut whole, never
without its id line, and degraded and chars_used describe the delivered text too. The
bound-record count of the nearest-anchored block reads "at least N" for a path whose bound
records include some of the nearest records shown.
drill_down bounds the whole answer, not only the decisions: a domain summary, titles and member
lines too long for budget_chars (measured as serialized JSON, a slug included) are clipped with a
marker, and subdomains or members that do not fit are left out with subdomains_omitted / members_omitted counts and a hint.
sidegraph-doctor no longer crashes on an index.db that has anchor_bindings but no meta
table: such an index counts as having its binding statuses computed, so the checks run. Only an
unreadable index is reported as skipped.
sidegraph-doctor no longer reports zero degraded-binding/orphaned-binding findings while
binding statuses are not computed for the current graph (after an index reload and before the
next sidegraph-sync). It lists binding-status among the skipped checks, and the text
line for it and orphaned-records no longer blames a missing index.db.
get_task_context no longer calls a real file of a sibling linked worktree "not a
repo-relative path", guesses it to be a same-named graph file, or reads a nested worktree path
as the main checkout's file. It names the worktree and branch; a well-formed path that is a file in no checkout is "not a
file in this checkout", and only malformed paths keep the old sentence. A path with records
bound to a file the graph lacks and not shown elsewhere in the answer says so in the
nearest-anchored block, and retrieve_decisions(files=[...]) reads such a path exactly.
Attribute each MCP call to its own session instead of the last SessionStart of any host
in the repository. A write that omits session_id and a retrieval-journal row now carry the
calling host's session: CLAUDE_CODE_SESSION_ID for Claude Code (2.1.154 or later for exact
attribution, 2.1.163 or later to keep one id across --resume) and the request threadId
for Codex. A client that names its host but no session falls back to the one fresh stamp of
the same host family, else to nothing; a retrieval that cannot be attributed is journaled as unattributed and sidegraph-stats reports those calls instead of counting their sessions
as silent. The sidegraph-mcp console script moves to sidegraph.host.mcp_entry:main
(the command name is unchanged; python -m sidegraph.server still starts the server, without
host identity). Hook commands set the new SIDEGRAPH_HOST variable. Hand-written
SessionStart wiring should add SIDEGRAPH_HOST=claude or SIDEGRAPH_HOST=codex.
Serialize Store state checks and publication behind a SQLite writer reservation,
including pending verdicts and server-side domain/fact preconditions. Busy admission
reports conservative inspect-before-retry guidance; file and index commits remain
separate operations.
Read graph/label JSON and Claude settings as UTF-8 independently of the default locale;
retry incomplete graph encodings and close temporary descriptors on setup failures.
Isolate noninteractive Git subprocess stdin from the MCP protocol pipe, resolving
native Windows first-write stalls in the installed-wheel stdio checks.
Set MCP isError for complete anchor-operation failures while retaining structured
payloads and partial-batch outcomes. Unknown tool names receive bounded discovery advice.
Changed
The Quickstart no longer installs the CLI for plugin users: its commands run through uvx,
as the plugin does. uv tool install sidegraph is documented as optional beside a plugin
and the usual setup without one, and its install now includes a mandatory update after every
release, uv tool install sidegraph@latest. The plugin's skills run every sidegraph-* command at the session's version through uvx and never the copy on PATH.
The PreToolUse hook records what it hands over as showings: each record printed in a
file-read block (inject) or appended to a subagent's brief (inject_brief) is journaled
and bumps retrieval_shows. sidegraph-stats gains a record showings delivered by hooks
line (activation.hook_showings in --json), and no recorded showing and the doctor's never-surfaced check count a record the hook printed as shown. Upgrade sidegraph-stats
together with the plugin: an older CLI reads the new journal rows as memory lookups.
The Agent-brief path, read-only until now, writes one transaction (up to the one-second busy
timeout under a held lock; a failed write never costs the brief).
Bound the output of five listing tools. list_facts, get_entity_history, drill_down, list_domain_candidates and list_proposed now take budget_chars (default 24000,
maximum 40000) and say what was left out and how to narrow; list_facts gains status, files, query, ids and limit, and get_entity_history gains status and limit.
Before, a store with a few hundred facts or a long-lived entity produced answers (up to
170,000 characters) that Claude Code replaced with an "exceeds maximum allowed tokens"
notice. Return shapes change:list_facts and get_entity_history return a JSON
object instead of a list; drill_down and list_domain_candidates add hint/truncated-style fields (omitted_ids, omitted_by_budget); the first four are sent
once, as text, with no structuredContent copy. list_domain_candidates shows fewer
candidates by default (the budget cuts first; narrow with paths; limit=0 still lifts the
count limit). retrieve_decisions's maximum budget_chars falls from 60000 to 40000, below
Claude Code's 50,000-character limit, and get_task_context, query_structure, query_decisions and list_proposed never return more than 40,000 characters (a cut says
so in a closing note and keeps the advice blocks).
Text-only MCP tools get_task_context, query_structure, query_decisions, and list_proposed no longer advertise an output schema or duplicate text in structuredContent. Machine-oriented dictionary results retain their schemas.
Bound supported dependency majors to FastMCP 3 (>=3.4.2) and MCP Python SDK 1 (>=1.24).
Added
A SessionStart advisory, path-skew, on macOS and Linux, when a sidegraph-* command on PATH (outside the session's own environment) is a different version than the session, such
as a forgotten uv tool install sidegraph. It reads the install's dist-info and runs nothing.
Per-process MCP tool admission: burst 200, refill 100 calls/second, immediate refusal
before tool work; initialization, ping, and discovery remain available.
Native Windows x64 CI for Python 3.13/3.14, covering selected core/MCP contracts and
an exact installed-wheel CLI/stdio probe outside the checkout.
Documentation
Reorganize the README around setup, usage, limits, and contribution paths; remove
unsupp...
Security patch for Sidegraph 0.11.0. Users of 0.11.0 should upgrade to 0.11.1.
Published on 2026-10-10.
Fixed
Bootstrap excludes special files and validates one bounded regular descriptor. FIFO,
sockets and devices cannot become selected text sources, including through --include.
Available nonblocking/no-follow flags guard final-component replacement; the opened
descriptor is rechecked before strict incremental UTF-8 validation. Directory aliases
are rechecked against exclusions. Include retains its size override but observed growth
past opened size is excluded. Later planner/apply reads and ancestor races retain their
existing scope.
Git verification preserves unusual filenames through binary NUL framing. Verify and
doctor detect immutable edits and archive deletions at paths containing Unicode, TAB,
LF, CR, quotes, backslashes or surrounding whitespace. Malformed or unsupported diff
output is an operational error (exit 1), never a partial clean report. Shared Git
callers keep their decoded-text behavior; only diff reads raw pathname bytes.
Git verification resolves --against to one protected, frozen commit ID. Verify and
doctor reject option-shaped, empty, NUL-containing, unavailable and non-commit baselines
operationally (exit 1). Diff and every baseline record read use the same full SHA-1 or
SHA-256 ID, so a branch moving between those operations cannot hide an immutable edit.
There is no raw-ref fallback. Current working-tree reads retain
their existing concurrency limits.
Malformed write diagnostics omit raw input and arbitrary failure causes. Capture
errors use bounded schema fields/codes; logical MCP writes apply raw admission before
signature coercion and intercept exceptions before framework warning/error logging.
Unknown write arguments keep valid parameter help without echoing their names.
Unsupported registrations fail closed. Partial-write warnings and recovery guidance
remain truthful; domain/ratify causes are controlled. Read diagnostics and upstream
FastMCP DEBUG argument tracing retain their existing scope.
Capture and direct writes reject oversized input before redaction or canonical writes.
Limits are 256 KiB UTF-8 per string, 4 MiB admitted strings and 65,536 admitted nodes per
request, depth 32 and 4,096 nodes per draft, and 100 agent drafts including attached facts.
Combined decisions/facts share one budget; item violations reject the containing draft,
while request-wide violations fail before any write. Documents have an independent 8 MiB
per-file limit with whole-file skips, matching real/dry reports and no total-import cap.
Import tags and free-text options are bounded separately; input is never silently truncated.
Raw Python batches use lists/tuples; supported scalar, mapping and model fields are
inspected. Other opaque Python values (including sets, deques, generators and non-string
Enum objects) are rejected before Pydantic coercion, without consuming iterators. Numeric
enums can become strings rather than behave as numbers in Pydantic string fields; only
string-derived enums keep their measured base-string behavior.
Redaction avoids repeated suffix searches in four known attack families. Linear
assignment-key, URL-credential, JWT and private-key-block recognizers preserve matching
order/counts and the existing conservative quoted-value behavior. This does not claim
universal regex linearity or complete secret detection. See security policy.
Secret-bearing tags are redacted before document import stores their slugs. Capture,
direct MCP writes and document import share tag normalization. Import reports tag
substitutions once per batch in real and dry runs, including duplicate raw tags.
Comma-separated MCP strings are cleaned before splitting; a secret-bearing string with
commas drops all its tags conservatively. List input keeps explicit boundaries.
Quoted assignment redaction consumes multiline, triple-quoted and unterminated values.
Escaped closers and glued tails stay covered. An unterminated value removes the
remaining input conservatively if no legal closer is found. An assignment span,
including URL/card expansion, that would swallow another assignment's key also consumes
the remaining input and following prose. A multiline quoted span splitting a URL
credential or checksum-valid grouped card number does so too. Bare values and
single-line glued tails extend through crossing URL/card matches while otherwise
preserving later prose. Prior tag entities and Git history remain: review old tags and
rotate exposed credentials. Unicode letters/digits after a single quote keep it inside
the value as an apostrophe; an unclosed value removes the remainder.
Secret scanning covers store-only commits and committed CI history. The pinned
Gitleaks hook runs even when changed files are excluded from formatting. A separate manual gitleaks-history hook scans committed HEAD history, also run by CI with a full checkout.
Scanner integration tests exercise staged, committed, and removed synthetic secrets.
Store verification checks the content of newly archived history. A matching archive ID
alone no longer permits deletion of a committed hot decision or domain: every archive copy
must be compactable and a legal transition from the baseline, preserving raw immutable keys.
The snapshot check also reports nonterminal archive records. Legal supersede/drop followed
by compaction, older absent null/empty defaults, identical merge copies, and crash cleanup
still pass.
Changed
Security documentation states the limits explicitly. The privacy overview describes
best-effort redaction and writes outside the store. CLI documentation distinguishes
argument syntax errors from rejected Git baselines and recommends JSON output when
a terminal cannot encode a reported pathname.
index.db no longer creates and deletes its journal in every write transaction. SQLite's
default rollback mode (DELETE) created index.db-journal at the start of each write and
removed it at the end, and the hooks write on every tool call, so one store produced
hundreds of thousands of file-system events an hour. Every connection that writes now runs journal_mode=TRUNCATE: the same rollback journal, locking and crash safety, but the journal
is emptied rather than deleted, so a 0-byte index.db-journal stays beside index.db
(gitignored by index.db*, as before). Each transaction still writes, truncates and syncs
that file, so the events do not vanish: they fall most for back-to-back writes (tests, bulk
imports: about nine times fewer on the journal) and by about a third for hook traffic.
Read-only connections still write nothing, and there is normally no -wal/-shm; an index
that another connection has pinned in WAL stays in WAL, which Sidegraph tolerates. A crash
mid-transaction still leaves a non-empty journal that the next writer rolls back.
Fixed
The UNANCHORED line of sidegraph-bootstrap now names the graph in use. It always said
to run graphify update ., which rebuilds only the default graph, so with --graph or $SIDEGRAPH_GRAPH elsewhere the advice could not make the next run write the document. It now
uses the same step as the ANCHORS line: rebuild the graph at <path> and rerun for a custom
graph, run graphify update . and rerun for the default one.
A fresh sidegraph-bootstrap run now lists the store's creation marker as changed. The CHANGED lines and --report named format and .gitignore but not the committed stamping_live_since file the same run created.
sidegraph-bootstrap now says what is wrong when a ratified or revived record is out of the
graph's reach. For an accepted candidate with no anchor in the current graph whose content
is already stored (ratified in place, or revived from a rejected record), every rerun used to
print ANCHORS incomplete: accepted candidate has unresolved or ambiguous anchors, PROOF incomplete: selected decision did not surface and a RESUME line that could never
succeed, and exit 2. The anchors are now judged by whether the
record still surfaces through production retrieval, the same test as PROOF: when it does
not, the run still exits 2 but prints ``ANCHORS incomplete: stored record for
has no anchor in the current graph; run `graphify update .` and rerun``, a matching
`PROOF` reason and a `NEXT` line with that command instead of `RESUME`; once the graph has the
node, the rerun exits 0. The exact `RESUME` stays when something a resume can fix (a failed
write, missing host wiring) is also wrong, and with `--graph` the line names the graph to
rebuild instead. The `--report` file carries the same remedy. A written record with an
unresolved backticked mention keeps its old message.
A directory named like a record no longer makes the whole store unopenable. A directory
(or a dangling or looping symlink) called .x.json, or 01ABC.json, in decisions/, facts/, domains/, entities/ or initiatives/ (or in bindings/ under a safe name, or archive/ as *.jsonl) crashed every open of the store, so every hook and MCP call failed
(IsADirectoryError for a directory, Too many levels of symbolic links for a looping
link), and a FIFO there blocked the open for ever. The reload now skips any entry that is not
a regular file before reading it, lists it in the skipped files with the reason not a regular file, and the startup warning names it; the other records load and the next open
takes the fast path. sidegraph-verify reports such an entry once, as unsafe-record-id
for an unsafe record name and as parse-error (bad-archive-segment in archive/,
whatever the segment's name) otherwise, and no longer blocks on a FIFO, including --against when a tracked file was replaced by one. A stale directory or looping symlink
named *.tmp no longer fails the open either. sidegraph-stats no longer ends in a
traceback on a looping link, and no longer reports the index as behind for ever because of a
dangling symlink. The skip reason for a file with an unsafe name now says unsafe filename
instead of "id 'A' does not match the filename".
sidegraph-prepare-commit-msg no longer leaves its candidate block in git commit -m
messages under the pre-commit framework. The framework passes the hook only the message
file and hands git's source in PRE_COMMIT_COMMIT_MSG_SOURCE, so the hook always saw "source
absent" and wrote its # Sidegraph: uncomment the trailers… and # Sidegraph-Decision: …
lines, which -m commits keep. With exactly one argument the hook now reads the source from
that variable (unset or empty means a plain commit); with two or three arguments, as raw git
passes them, the arguments still win.
The hook entry points answer --help instead of running.sidegraph-session-start --help
ran the SessionStart hook in the current directory and wrote into its index.db, and the other
three hook scripts (sidegraph-stop, sidegraph-pre-tool-use, sidegraph-subagent-start)
ignored their arguments the same way, while sidegraph-prepare-commit-msg --help printed
nothing and sidegraph-mcp --help started the server. Each now prints a short usage text for -h or --help and exits 0 before it reads stdin, opens the store or writes anything. Any
other argument to a hook script is a usage error on stderr with exit 2. The git hook sidegraph-prepare-commit-msg stays fail-open: it prints the same error for an unknown option
or a fourth argument but exits 0 and leaves the message untouched, so git commit never fails
because of a stray args: entry; git's one to three positional arguments are unchanged. Hosts call the hook scripts without
arguments, so nothing a host runs changes.
sidegraph-bootstrap now converges when an accepted candidate has no anchor. A document
with no backticked symbol and no node in the graph (typically one newer than the last graph
build) was never written, yet stayed pending: every run, --resume included, ended partial-recoverable or incomplete with exit 2 and a FAILED REF <none> the operator
could not act on. Review now flags it with a no-anchor warning, and apply skips it by name
on an UNANCHORED line with its source path (skipped_unanchorable_keys in the report object)
and counts it as neither durable nor pending, so it no longer keeps the run partial. A run
where every approved candidate is skipped this way writes no record and ends as a diagnostic
that exits 0. It keeps the checks a run with records has, except the anchor and proof ones:
the store reopens and verifies clean, the host integration is verified, and --report is
written. A missing host integration or a store violation exits 2 instead (STORE incomplete, with RESUME, or sidegraph-verify for a violation), as does an anchored
candidate kept proposed. Run graphify update . (or rebuild the --graph file) and the next
run writes it. sidegraph-import is unchanged.
sidegraph-verify now reports a domain whose parent_id is not a domain, or whose parent
chain loops. The store refuses to write either, but a hand-edited domain with parent_id: "not-a-domain" passed sidegraph-verify and sidegraph-doctor with exit 0. A parent_id that names no domain, hot or archived, is now a dangling-parent, and a parent
chain that comes back to itself (a domain that is its own parent included) is one parent-cycle per cycle, at the domain with the smallest id on it. A superseded, dropped or
compacted parent still counts as existing, so a history written through the tools stays
clean, and a parent held by a file skipped for its name is not reported twice. A non-string parent_id stays a parse-error (a bad-archive-segment in an archive segment).
A record directory the process cannot list or search is reported as unreadable, not as
missing records. With decisions/ at mode 000, sidegraph-verify printed a dangling-fact-support for every fact and --against an illegal-deletion for every
record, as if the store had been tampered with. sidegraph-verify and sidegraph-doctor now
stop with one store not readable line naming the directory and exit 1, before any
cross-reference or deletion check runs. sidegraph-stats likewise says cannot read the store index instead of no store index when the store directory cannot be searched, and no
longer ends in a traceback when a record directory cannot be listed. An empty record
directory without the search bit is reported the same way.
sidegraph-verify reports only unsafe-record-id for a record file the store skips for
its name or id. A copy such as decisions/.x.json was also reported as filename-id-mismatch and duplicate-ulid, and a file with a safe name but an unsafe id as filename-id-mismatch too, though the store never indexes either. A file with an unsafe
name that was not valid JSON, not UTF-8 or not an object showed only parse-error, and the
same went for a bindings/ file that was not a JSON list, so the real problem, the name,
went unreported. The name is now checked before the content, in every record directory. A
file skipped for its name no longer counts as a second copy of a record, but a record that
points at its id is not reported either: renaming the file back is the only fix.
sidegraph-compact no longer warns "DIFFERENT content" for a hot file that only lacks
newer keys. A decision or domain written before ratified_at, ratified_by or provenance.commit existed has no such keys, while...
Every MCP tool now carries annotations, so a host's auto-reviewer stops rejecting memory
reads. In the field, Codex's auto-reviewer rejected 20 get_task_context calls, and each
one lost memory for that step: an unannotated tool takes the MCP defaults (destructive,
open-world), and the review's verdict then depends on the prompt. The seven tools that never
change a tracked file are read-only; the other 17 are non-destructive, closed-world local
writes. No tool sends anything off the machine and none deletes a record. A test runs the
read-only tools on a store where a lazy sync would adopt a moved symbol, and fails if one of
them changes a tracked file, so the annotation cannot drift from what the tool does. See the reference and the Codex page.
The seed tools accept the argument names agents try, and every tool names its parameters in
an error.get_task_context, query_structure and query_decisions accept paths (as files) and seeds; each seed goes to files when it looks like a path and to entities
otherwise. task is refused with a line saying there is no free-text query. Any other
unknown argument, on any tool, is an error that names it and lists the tool's real parameters;
FastMCP's own error named the argument but not the parameters.
Changed
retrieve_decisions no longer returns the whole store. One call returned a full dump of
every visible decision: about 143,000 tokens on a store of 260 records, more than a host would
show, and in a field report most agents passed include_superseded: true and then either
printed the dump or filtered it by hand. The call now has two answers.
With no narrowing filter it returns an overview: the total, counts by status and kind
(superseded and rejected records included, so the counts say that history exists), and the
newest records as compact rows (id, kind, status, title, valid_from).
With a narrowing filter it returns full records, gotchas and lessons first and newest
first, cut at limit (default 25, at most 100) or budget_chars (default 24,000, at most
60,000), whichever comes first. A hint says what cut the list, or why it is empty.
The filters are new arguments, and they combine with AND: status and kind (a string or a
list), files, query (every word must occur in the record's text) and ids (exact record
ids, superseded ones too). include_superseded keeps its meaning: it widens the overview's newest and a filtered call that has no status.
files are read the way get_task_context reads them: a basename, a suffix, a directory or
a letter-case slip works. unresolved_files lists, for each path nothing could read (at most
ten, with unresolved_omitted counting the rest), why (ambiguous, directory-too-large, not-in-graph or no-graph) and the candidates to pass instead.
The return type changed from a list to a JSON object, sent once as a single text block
with no structured copy, so budget_chars is the real cost on every host. A client that
read the list now reads decisions of a filtered answer. The proposal policy is unchanged
and applies to every shape.
The check-plan, explain-why, triage-drift and heal-anchors skills now call it with a
filter, and a test fails if a skill calls it without one. See the reference.
Fixed
A fault converting a reviewed item in sidegraph-bootstrap now prints the RESUME line. The
conversion of each reviewed item, and the check that its ref still matches the candidate,
ran outside the per-item error handling. A fault there, after an earlier item had been
written, left apply_review without a report: an AssertionError ended the run as a
traceback, a ValueError was reported as a failure before anything was written, and neither
printed RESUME, though canonical files were already on disk. The fault now takes the path a
write error takes: the run stops, reports partial-recoverable with the durable candidates and
the failing ref, and prints the resume command. A fault on the first item, before anything is
written, now exits 2 with STORE incomplete, the failing ref and the resume command, where a ValueError used to exit 1 with an ERROR line; the terminal shows the failing ref and not the
reason.
sidegraph-import --dry-run now says what auto-ratification would do. Both import paths
decided auto-ratification only after a real write, so under a non-manual SIDEGRAPH_RATIFY_POLICY a dry run could not show which records a real run would land
accepted without a human: on one fixture it reported 0 where the real run auto-ratified 2. The
dry run now runs the same eligibility gate on the record the write would produce and reports
the result in a new would_auto_ratify field on ImportReport and DocImportReport, and as auto_ratify_eligible on each dry-run item. auto_ratified keeps its meaning, transitions
that actually happened, so it stays 0 in a dry run.
Behaviour change: under a non-manual policy the summary line gains would auto-ratify M before (skipped: and an eligible item line ends with [auto]. Under manual both are unchanged. A dry run still never carries the real run's auto-ratified K.
The count is eligibility, not a guarantee: a real run's transition can still fail, for
example on a race or a refused cascade.
A key repeated within one run no longer counts twice in the dry run. Two rationales with the
same title in one file count once. A document ref met more than once (one file named twice, a
directory plus a file inside it, or a live and an archived copy that a profile maps to one
ref) is classified against the records the earlier copies would have left there, as the real
run sees them: an identical repeat is skipped, a differing one supersedes, and a copy after a rejected first copy is a fresh write. Two rare shapes can still differ in the superseded/skipped split, not in the eligibility count where checked: a ref met three
times with the first content returning, under an auto policy, and a repeat with no anchor of
its own.
find_entity no longer calls a dangling record a decision. Each binding was labelled fact if a fact lookup found it and decision otherwise, so a binding whose record exists in
neither table (a record file removed by hand, or lost in a merge) came back as a decision that get_decision could not fetch, and each binding cost a query of its own. The types of all of an
entity's bindings now come from one read of the index, and a binding with no record reports record_type: "unknown". The value is additive: a client that handles decision and fact
is unaffected. get_entity_history uses the same read and still skips such a binding. See the reference.
A document status like should not be rejected no longer lands rejected. The check for
a turned-down status saw a negation only as not and one space directly before the word, so should not be rejected, not rejected (two spaces), never rejected and isn't rejected
all read as rejections, and a false rejected lands closed and invisible to retrieval. A
negation is now not, never or a word ending n't within the three words before the word in
the same clause. A clause ends at ,;.:, a pipe, a slash, an em or en dash, or a spaced
hyphen or ->, so not reviewed, rejected, Not accepted — rejected and Won't do / rejected
still read as rejections, as does not rejected, rejected. A conjunction does not end a
clause: Not adopted and rejected reads as negated, and lands accepted unless --propose or
a draft marker sends it to the ratification queue. See the reference.
Host wiring that silently switched memory off is now visible. Two field failures went
unnoticed for weeks: hooks that ran an old install under a newer plugin, and sessions started in
a subdirectory where the plugin was off. SessionStart now prints Sidegraph <version> right
after the standing instruction, and two advisory checks report the wiring, each with a notice
at most once a day (plugin-off-in-subdirectories only when a nested directory's own settings
leave the plugin off).
version-skew compares the running package with the plugin manifest under $CLAUDE_PLUGIN_ROOT (or else $PLUGIN_ROOT, which Codex sets) by numeric parts, and says
which side is stale. It does not run without a plugin root, a manifest named sidegraph, or a
version.
plugin-off-in-subdirectories applies Claude Code's settings model (a launch directory's own
project settings only, the repository root's .claude/settings.local.json for every launch, enabledPlugins merged key by key over user settings) to the directories of the repository.
It reports a plugin enabled only in the root's .claude/settings.json, and nested directories
whose own enabledPlugins leave it off. User settings are read from $CLAUDE_CONFIG_DIR/settings.json when that variable is set, else from ~/.claude/settings.json. It also lists in sidegraph-doctor under the finding code plugin-off-in-subdirectories, and --check exits 2 on it like any finding.
The getting-started pages now say that enabling the plugin at user scope, or in the root's .claude/settings.local.json, covers every subdirectory, and the setup skill reads the doctor
finding. See troubleshooting.
A subagent's brief carries the records for the files it names. The task a subagent works
on is in the brief its parent wrote, which the SubagentStart brief cannot see. The sidegraph-pre-tool-use entry point now also handles Agent (and Task, the older name): it
reads the files the brief names, and the files named in a .md, .txt or .rst document the
brief names (one hop, the first 64 KB), finds the records anchored to them, and returns the
call's own input with a block appended to prompt (updatedInput; no permissionDecision).
The subagent finds the block at the end of its first message. The parent's view of its own
call does not show it: its transcript keeps the prompt it wrote. A path resolves exactly when
it is an existing regular file inside the project root, and otherwise by a unique suffix among
the anchored files (sub/x.py for pkg/sub/x.py; an ambiguous suffix names nothing), tried
from the directory the agent was launched in before the root. A document is followed only when
it is a regular file inside the root, and no hop goes through a project-instruction file
(CLAUDE.md, AGENTS.md and their .public twins): they are still shown when records are
anchored to them, but the files they list are not followed. The block is grouped by file, two
records each, at most six record lines and 3,000 characters, and it ends with a More: get_task_context(files=[…]) line for the files shown; a file that misses the sixth
line by one still gets its first record there. It is on by default; SIDEGRAPH_AGENT_BRIEF=off disables it. The plugin manifests (Claude Code only, the Codex ones
are unchanged) and the manual recipe gain a third PreToolUse group, matcher Agent|Task, on
the same command; a hand-wired setup needs that group added. Not tested: agent-team teammates
and fork spawns. See the hooks reference.
Every subagent starts knowing that memory exists and how to ask it. A subagent gets no SessionStart context, and Explore and Plan agents load no CLAUDE.md either, so nothing
standing told one that decision memory exists. The new sidegraph-subagent-start hook, wired
to SubagentStart in the Claude Code and Codex plugins, answers with a short brief: the same
standing get_task_context(files=[…]) call SessionStart gives the main agent, and one
sentence of what memory holds ("N records anchored to code in M files, K of them recorded
mistakes"). The counts are what get_task_context could return: live records that may surface,
with a live or degraded binding to an entity that names a file. It carries no records, prints {} where there is nothing anchored to code, and, like PreToolUse, reads the index directly
and builds no Store. SIDEGRAPH_SUBAGENT_BRIEF=off disables it. Codex records trust per hook
definition, so approve the new hook once in /hooks; manual recipes for both hosts gain the
entry. See the hooks reference.
Changed
The capture nudge comes back after more work, instead of firing once per session. A session
that runs for a day and spawns subagents used to get one chance to record what it decided, at
its first substantial Stop, often before the work worth recording. A session that was already
nudged is now nudged again when at least 30 minutes have passed since its last nudge and at
least 10 commits authored since then are reachable from a local branch or HEAD; the text of
that nudge ends with Since the last capture prompt: N commits., and the first nudge is
unchanged. The count is one git log --branches HEAD, run in the project the store belongs to
with a 2-second timeout, and it goes by author dates, so a rebase or cherry-pick of old commits
is not new work. A git failure means no re-arm. The last nudge is the meta key capture_rearm:<session>, which the first nudge writes too and SessionStart expires after 30
days; a session captured by an older version has no key and counts from the captured_at of its capture_sessions row. The new stamp is written with a compare-and-swap, so two parallel Stop
hooks cannot both nudge. A Stop inside the 30 minutes still exits on the read-only peek and
runs no git: a subprocess measured on a copy of this repository's store with a 1,000-commit
repository takes about 39 ms inside the gap and about 47 ms past it with too few commits. The
nudge's length bound is 850 characters, up from 800, for the added sentence. The count includes
commits that other sessions or people make on the same machine's branches. SIDEGRAPH_CAPTURE_NUDGE=off silences re-armed nudges too. See the hooks reference.
PreToolUse hands the agent the records of a file when it reads or edits it, instead of
asking it to fetch them. The hook used to print a one-line nudge (a count, or the titles of
two records) and ask the agent to call get_task_context, which for a tool the host lists only
by name means loading it first. In the field study the one nudge that named a record worked and
the five that named counts did not, Edit and Write never nudged, and Bash, which is how current
Claude Code reads and searches, was never wired. The hook now prints the records themselves: the
guard line, then for each file a header and its top two records (three when the first two are
mistakes), each as - [kind] title — first sentence of the choice (id …), accepted mistakes
first, proposals last and marked [unratified], and a More: get_task_context(files=[…]) line
when records were left out. It covers Read, Grep, Edit and Write, and four Bash read
commands, sed, grep, rg and cat, wired as four if entries on one command (if needs
Claude Code 2.1.89 for correct matching; an older host runs the hook on every Bash call, which
exits early with no output). A Bash line delivers the first three files it reads that have
records. Each file arrives once per agent, and an agent receives at most ten files: one SQL
statement counts and inserts a pretool_file:<session>:<agent>:<path> key, where <agent> is - for the session's own agent, so concurrent hooks cannot exceed the cap. The counting form
(this project has a decision memory … N domains, M decisions) is gone, the pretool_nudge:
and pretool_nudge_path: keys are no longer written (SessionStart still prunes them, and the
new keys after 30 days), and a Bash read is still not a touch event. SIDEGRAPH_GREP_NUDGE=off
keeps its name and now turns off all of this, on every tool that reads (not the Agent
brief below, which has its own switch); it stops neither the hook from
launching on a Bash call nor touch recording. A subprocess median of nine on this repository's
store: about 26 ms when nothing is delivered, about 35 ms for a Read of the file with the most
records, about 38 ms for a Bash line naming three anchored files. A hand-wired .claude/settings.json gains the Bash group (see the setup guide). The PreToolUse
commands, in the plugin and in that snippet, no longer set SIDEGRAPH_GRAPH, which the hook
never read: Graphify's graphify claude install and uninstall remove a Bash entry whose
text mentions graphify, and would otherwise have removed the hand-wired group. See the hooks reference.
The plugin's hot hooks start from the commit SessionStart resolved, not from @main. uv re-resolves a branch reference on every call, about 0.8 s on a warm cache, and Stop and PreToolUse run many times a session. SessionStart now records the commit uv resolved for
an @main install in ${XDG_CACHE_HOME:-$HOME/.cache}/sidegraph/launch-commit: 40 hex bytes,
one file per machine, never written by an install pinned to a tag or SHA, an editable install
or a PyPI install. The public Stop and PreToolUse commands (Claude C...
get_task_context says where the memory is when your file has none. A file seed the graph
lacks, or one that resolved with no current record anchored to it, now gets a ## Nearest anchored
sentence naming up to three files that do carry records: the graph neighbours first (the two
with the most records), then the files under the nearest directory that has any, skipping a
directory that holds more than a quarter of all anchored files (and more than four) and never
the repository root.
When the answer has no decision memory at all, the records of those files follow under ## Nearest anchored records (not anchored to your files), labelled as not about your files, with
the same supersede line the main answer carries. An empty answer carries the sentence under ## Why this is empty. At most three seeds get a sentence. The records count as shown in sidegraph-stats, and the neighbour files are never recorded as seeds. See the tool reference.
Records that never reach the team are now visible. A record follows the branch and
checkout it was written on, and on one field corpus two supersessions sat uncommitted for five
days and 32 proposals lived only on branches that main's session start never saw. Two new
checks run at session start. store-uncommitted (degraded, with a notice, also in sidegraph-doctor and sidegraph-stats) reports store files nobody committed for a day, aged
by the ULID in a new file's name (a git stash cannot rewrite it) and by the modification time
otherwise; fully staged files do not count, so sidegraph-doctor --check in a pre-commit hook
never blocks the fixing commit. branch-only-records (advisory) scans every unmerged local branch,
oldest first, for open records the default branch lacks (each counted once, for the newest
branch that holds it), and sends a notice when such a branch is more than a week old: the fix is
to merge it. The write tools (add_decision, supersede_decision, add_fact, supersede_fact, add_anchors, add_domain, supersede_domain, and the written elements of propose_decisions and propose_domains) add a commit_hint to their result when the store is inside a git repository, except a store that is a
symlink to a directory outside it: git commits the link, not the records behind it. Where records
are written does not change. See the troubleshooting guide and the MCP tools reference.
Sidegraph tells you when it needs attention, not only the model. One list of integrity
checks now runs at every session start and in sidegraph-doctor and sidegraph-stats. The model
still gets a status line per problem, with the five existing lines unchanged and in the same
order. Problems that need a person also reach you as a systemMessage, which Claude Code shows as
a warning: at most once a day for each check, again at once when a problem gets worse, and not
again once the check finds nothing. New checks: a store that cannot be opened (a merge conflict
left in .sidegraph/, a damaged index, a store written by another version) now says so and
names the fix instead of switching memory off silently; a missing or unreadable code graph;
open records whose every code anchor is orphaned (degraded, with a notice, when three or more were
written in the last 14 days; advisory otherwise); and store files the last reload could not
index. A proposal that has waited 30 days earns a notice too. sidegraph-doctor gains an orphaned-record finding and, with no graph, a skipped-checks line ("graph" in --json's skipped) instead of a finding, so --check does not start failing CI jobs that run without
one. sidegraph-stats opens with a HEALTH line (health in --json). The checks, their
severities and fixes are in the new troubleshooting guide, and
the output is in the hooks reference.
sidegraph-init asks whether to keep the code graph fresh, and installs the hook that does
it. A graph nobody rebuilds goes stale, and on one field corpus it fell 314 commits behind
because the setup hint was printed and skipped. In a terminal, init now asks once (default yes); --hooks installs without asking, --no-hooks declines and records it
(git config sidegraph.graphRefresh false), and --remove-hooks takes everything out and exits
without creating a store (it exits 1, and says the removal was partial, when a hook is a symlink
or has damaged markers and so keeps its block). The hook is a marked block in post-commit, post-merge and post-checkout, inserted right after the shebang line so that a foreign hook that ends in exit
cannot skip it, plus a helper that rebuilds the graph in the background, in the main checkout
only (a linked worktree reads its graph), one rebuild at a time. It never replaces a hook: one that
is a symlink, not executable, CRLF or not a shell script is left alone and the line to add by hand
is printed. With core.hooksPath set it writes no hook file and prints the lines instead. Without
a terminal nothing is written, and a new advisory check, refresh-hook-missing, tells the model
to ask you, and tells you once a day, until the hook is installed or declined. A call you add by hand
counts only when its hook is executable and the call is not in a comment. The three flags are
a Committed surface (stability); see keeping the graph fresh and the troubleshooting guide.
Changed
get_task_context reads a wrong seed the way you meant it, and an empty answer says why.
A seed the graph does not hold as written used to come back as "No context found." Now ./x.py, x.py:12, an absolute path inside the repository, a missing or invented directory prefix, a bare
file name, a directory, and a Type.member with no file are read through a short ladder, and a ## How your seeds were read block says what each one became and that a rewrite is a guess. A
file that exists on disk but not in the graph is never swapped for a namesake. A directory reads
as up to eight files, or is listed when it holds more than 24. A path that differs from a graph
file only by letter case reads as that file, even on a case-insensitive filesystem. A path or
name that could mean several is reported and read as none. Guessed seeds rank in the related
tier below the seeds you got right. The not-in-graph block now names the normalised path, so ./pkg/n.py gets the stale-or-new advice instead of "check the path". A name with no file that
matches several symbols is no longer expanded to all of them: it is reported as ambiguous. A call
with no seeds at all names the accepted domains you can drill into. Telemetry records a rewritten
path under the path that was read and a directory under its normalised key, and keeps a seed
whose name starts with .. that it used to drop. See the tool reference.
Fixed
A symlinked store's capture commit, code drift and refresh-hook check come from the project
that holds the link. A store can be a symlink (.sidegraph -> ../shared/store). Its records
are anchored to code in the project that holds the link, but git resolved the link and answered
for the repository it points into: provenance.commit named that repository's HEAD, or was
empty when the target sits in no repository, so the code-drift check passed silently or never
ran, and the refresh-hook check read the wrong hooks. Capture now stamps the project's HEAD
(empty while the project has no commits), the code-drift check diffs it in the project's
repository, and the refresh-hook check reads the project's hooks and recorded choice, the ones sidegraph-init writes. A store that is not a symlink behaves as before, and --against (sidegraph-verify, sidegraph-doctor) still diffs the history of the repository
that holds the store's files. Three behaviour changes come with it. A store directory that is itself a repository or
submodule inside another repository now stamps the enclosing project's HEAD and measures drift
there, and its refresh-hook check now runs against the enclosing project; a top-level store
repository with no enclosing one is unchanged. A record stamped before the fix with the link
target's (or the nested repository's) HEAD now gets sidegraph-doctor's existing code-drift: git unavailable — check skipped note for that commit, where it used to pass
silently. And the records of a symlinked store now match in git-bindings rule (a) and in sidegraph-blame, which read provenance.commit. See the store
format.
Security
The lockfile moves PyJWT from 2.13.0 to 2.15.1 and virtualenv from 21.7.0 to
21.14.4. The PyJWT 2.14.0 and 2.15.0 fixes cover one critical advisory (an asymmetric
PEM key with mutated whitespace skips the HMAC key-confusion guard), five high ones (key
material accepted as HMAC secrets, JWKS redirects, a BOM bypass) and several moderate
ones. The virtualenv fixes cover seed wheels that were not integrity-checked and
activation scripts that run commands embedded in a path or a prompt. PyJWT reaches Sidegraph only
through the MCP SDK's crypto extra, and Sidegraph never decodes a token; virtualenv is
a development dependency through pre-commit. The published package pins neither, so this
changes development and CI environments and installs made from uv.lock; a fresh install
alr...
The hooks and the MCP server look a missing relative store up inside the repository. The
precedence between --db, SIDEGRAPH_DIR, SIDEGRAPH_DB and the default is unchanged, and a
store that exists where its relative path is anchored still wins. What is new: when a relative SIDEGRAPH_DIR (or the default .sidegraph) does not exist there, the three hooks and the MCP
server visit the parent directories, nearest first, up to and including the repository root (the
nearest directory holding a .git entry, a directory or a file), and use the first store found.
The lookup never leaves the repository (a .git entry at your home directory or above it is not
a repository root, so a dotfiles repository does not turn ~/.sidegraph into every project's
store) and does not run for --db, an absolute SIDEGRAPH_DIR, SIDEGRAPH_DB, a value with a .. part, or an anchor that is a symlink. The CLI, sidegraph-init
and sidegraph-bootstrap are unchanged: from a subdirectory they still need --db. To keep the
old outcome on a host surface, pass an absolute SIDEGRAPH_DIR.
Fixed
A SessionStart hook registered twice on one host no longer injects the map twice. Two
copies firing for one session at once both read the old duplicate-check ledger and both emitted;
the check now decides and stamps inside one write transaction, and a duplicate still answers
without waiting for a lock. The ledger keeps one row, so a repeat is still let through when
another session starts in between, or when two sessions with doubled hooks start together.
Sync's moved rung adopts only a real move. When an anchor's file is gone, sync looks the
name up across the whole graph and follows a unique hit to its new file. That lookup ignores
case and decoration, and the check behind it (the old path is out of HEAD, the new one is in
it) did not ask whether the new path was there before. A type deleted together with its file
could therefore be re-anchored onto an unrelated file that held an old member of the same name:
a deleted Priority followed .priority into another file, silently rewriting the entity's
committed descriptor.file_path. Two rules now close it. The hit must be the same name once
the call decoration ((), a leading .) is stripped from both sides, with case kept. And git
must show the new path arriving in the change that removed the old one: the mainline commit that
deleted the old path (a branch merged with --no-ff counts as one change) must have a first
parent without the new path and must hold it itself. With SIDEGRAPH_TRUST_DIRTY_TREE=on and the
old path still in HEAD, the new path must be absent from HEAD. Every git failure refuses, as
does a history with no deletion in it, so a move out of a path git never held (a gitignored
directory) and a move that a shallow clone cannot prove now end orphaned, which sidegraph:heal-anchors repairs, instead of being adopted. An uncommitted delete onto a path
that is already in HEAD is orphaned too, not remembered as a move waiting for a commit: no
commit can make it one. The search matches the old path literally (a path such as app/[id]/view.tsx is not a glob) and ignores log.showSignature.
Some real moves are refused as well, because git's history alone does not show them as one
change. Each ends orphaned and sidegraph:heal-anchors re-anchors it:
the new file added in one commit and the old one deleted in another on the first-parent line,
including a branch merged fast-forward or rebase-merged;
a second move before any sync ran (A to B to C while the descriptor still names A);
an old path that was re-added and then deleted again.
To check what an earlier release adopted, list the descriptor rewrites: git log -p --diff-filter=M -- .sidegraph/entities/ | grep -E '^(commit |[-+]\s+"file_path")'.
A rewrite is a real move only if the new path was added by the commit that deleted the old one
(git log --first-parent -m -1 --diff-filter=D -- <old path> finds that commit; the new path
must be missing from its first parent and present in it). Re-anchor a wrong one by superseding
the record with fresh anchors.
The SessionStart instruction names a call the tool accepts, and a domain's mistake count
counts what the domain holds. The standing line told the agent to call get_task_context(seeds), but the tool takes files and entities and rejects anything else,
so an agent that followed it made a call that failed validation. It now says get_task_context(files=[…]) with repo-relative paths, points at the check-plan skill before a
non-trivial change when it is available, and says to load the tool first if the host lists it
only by name. Separately, the per-domain count in the SessionStart map read "0 mistake(s)"
over a domain whose gotchas were anchored to its own code, because it counted only decisions
tagged to the domain itself. It now counts the accepted gotcha, lesson and constraint
decisions that drill_down serves for the domain, so the two show one number. The map's cache
records the store's digest, so a retrieval call on an unchanged store no longer rebuilds it, and SessionStart reads the unratified list without building the whole map. The bindings table gains
an index by entity (an existing store gets it on its next open), which takes a rebuild of the map
from about 0.1 s to under 0.03 s on a real store, so the cost of a session start is what it was
before the count changed, also right after a store change and in a linked worktree. A worktree
builds its map with the main checkout's graph reader, so it counts the same decisions the main
checkout does. A map written by a path that has no graph reader (ratify, capture) leaves out
decisions anchored to a whole document until the next sync.
A Claude Code subagent gets its own read nudges, and its touches are told apart. A
subagent's hook payload carries its parent's session id plus an agent_id of its own, and
the PreToolUse nudge keys were per session: once the main agent had been nudged, every
subagent it started read anchored files blind. Each agent, the session's own and every
subagent, now gets one generic and one path-specific nudge. The
keys are claimed in a single statement, so a subagent's parallel opening reads cannot both
nudge, and SessionStart expires keys older than 30 days. The retrieval_events journal
gains a nullable agent column, added to an existing index on its next open, that records
which subagent made a touch; the row stays under the parent's session, so sidegraph-stats
counts the same sessions as before. Only agent_id counts, never agent_type: a main
session started with claude --agent <name> is still the session's own agent.
An anchor written Type.member now resolves to the member. Graphify labels a method .playClip() and links it to its type, an agent writes AudioPlayback.playClip, and the two
never met: the anchor was born orphaned, and a seed naming it never reached the record, which
surfaced only through its community, as an unratified proposal. With a file_path, a name
with no exact match now falls back to a member of the named type in that file. It is for code
identifiers only (prose, a numbered heading or a hyphenated file name never reads as a
member). The name is split at the last ., :: or # and the member is compared with its
case kept. A candidate that an edge ties to a different type is refused, and the answer stays
orphaned, never guessed, when several remain or when the file holds a case-only twin of the
member (a struct Message beside a method .message(), which the store treats as one
anchor). Without a file, and for a path-qualified name (src/pkg/mod.py), nothing changes.
On the read path a seed that names a stored anchor, and every node a seed resolves to, now
map to their store entities by the engine mapping, in the neighbour walk as well, so such a
record ranks as a mistake or decision again. The first sidegraph-sync after the upgrade
reruns once on its own (the sync stamp now carries a resolver revision, which a report does
not show) and heals the orphans this left behind. The skills and guides no longer say Type.member names never resolve; the bare name with its file stays the preferred form.
A hook command that cannot start no longer blocks or loops the host. On Stop, both hosts
feed a hook's stderr back when the hook exits 2: Codex continues the turn, and Claude Code
continues the conversation, and Claude Code also blocks the tool call on PreToolUse. uv
exits 2 on some of its own errors (a project it cannot find, a cache it cannot write) and dash
exits 2 when the cd fails, so a hook command that never reached Python could do exactly that,
over and over in a headless codex exec run. Every shipped hook command, in both plugins and in
the manual recipes in the docs, now ends with a guard: Stop and PreToolUse answer {}, and SessionStart answers a one-line systemMessage that says the hook could not start and to run
the hook command in a terminal to see the error. A command that starts Python behaves as
before. Codex users: approve the two hooks again once after updating. Codex records trust
per hook definition and the definition includes its command, so the changed commands do not run
until you approve them in /hooks or at the next interactive session's trust prompt. If you
wired the hooks by hand, append the guard to your own commands in .claude/settings.json or .codex/hooks.json; the recipes in the docs show it. The Codex page also now says how to run a
headless review without Sidegraph's hooks.
The capture nudge now arms on Codex. The Stop hook's substance gate ...