Sidegraph

Latest version: v0.13.0

Safety actively analyzes 1035937 Python packages for vulnerabilities to keep your Python projects secure.

Scan your dependencies

Page 1 of 3

0.13.0

Fixed

- 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.gitmain`,
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.gitmain`,
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.

0.12.0

Fixed

- 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 sidegraphlatest`. 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
unsupported comparisons and clarify the runtime versus host data boundary.
- Document tested platform coverage, the locked MCP protocol range, result error
handling, and recovery after a cancelled write. Align guides and reference pages
with the current release and stability policies.
- Explain Windows plugin shell/PATH requirements, WSL setup, native MCP-only limits,
and the distinction between core/wheel CI and full agent-plugin validation.

0.11.1

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](SECURITY.md).

- **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.

0.11.0

Changed

- **`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 <id> for
<source> 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 its archive line, dumped from the model,
carries them as `null`. The raw comparison read that as a difference, kept the hot file and
repeated the warning on every index rebuild and every later compact. The visible damage was
that `sidegraph-verify` then exited 2 with a `duplicate-ulid` for each kept hot file, so the
store-lint gate failed after a compact. A key the hot file lacks now matches an explicit
`null` or an empty list or object in the archive, recursively, as `sidegraph-verify
--against` already treats it. The match is one-directional: a key only the hot file holds, at
any depth and whatever its value, still warns and keeps the file, so a field written by a
newer version is never lost to a compact by an older one.
- **`sidegraph-doctor` keeps its `auto share` and `auto supersede rate` lines on a hand-edited
archived record.** An archived decision, a fact or a domain whose `status` was a list or a
dict made the auto-share tally raise, and doctor swallowed the error and dropped both lines
without a word. The bad value now counts as not retired and the lines print. Any other error
in the ratification lines is no longer swallowed.
- **`sidegraph-verify` no longer crashes on a non-string `supersedes`.** A hand-edited record
whose `supersedes` was a list or a dict ended the run with a `TypeError` and lost every
other violation. The bad value is now reported as the violation it is and the run goes on.
- **`sidegraph-verify` no longer crashes on an unhashable `supports` item, a status in
`--against`, or the malformed id of a deleted record.** Each used a raw hand-edited value as a
set member or lookup key; the run died with exit 1 where it should find the violation and exit
2. A malformed old status in `--against` is now reported as an illegal status jump.
- **A symlinked store directory now stops `sidegraph-verify` and `sidegraph-doctor` at the
link.** Verify reported the link but still read through it: `facts -> decisions` linted every
decision as a fact, a link out of the store linted foreign files, and a linked-out `entities`,
`decisions` or `archive` made references into it look dangling. Both commands now report the
`symlinked-store-entry` findings, skip every other check (doctor's advisory checks and
`--against` included) and print one line saying so in text output; the exit code stays 2. An
`--against` ref that does not resolve, or a store outside git, is still checked first and exits
1 without reporting the links. A
symlinked `format` marker is no longer read, so its target's text cannot reach a CI log. A bindings file with
an unsafe name no longer also reports its entities as dangling, since the store skips that
file whole.
- **`sidegraph-verify` and `sidegraph-doctor` report a store directory they cannot search as
`store not readable` on every Python.** With the store directory (or a parent) lacking the
search bit, for example `chmod 000` or `chmod 600 .sidegraph`, Python 3.14 reported
`unknown-schema-version … format marker missing` or `store directory not found` with exit 2
or 1, which read as a bad schema version or a missing store; Python 3.13 already printed
`store not readable (…)` with exit 1. Both commands now look the directory and its `format`
marker up with `os.stat`/`os.lstat`, which raise on every Python, and take the exit-1 path.
A directory that is searchable but not listable (`chmod 300`) is still checked normally.
The MCP `verify_store` tool now raises on 3.14 as it did on 3.13 instead of reporting
`unknown-schema-version`, and a store path that is a symlink loop reports the OS error
(`Too many levels of symbolic links`) instead of `store directory not found`, still with
exit 1.
- **`sidegraph-doctor` no longer crashes on a list or dict `status` or `entity_id`.** A
hand-edited decision whose `status` was a list or a dict, or an anchor-set entry whose
`entity_id` was one, made the dangling-record, unreferenced-entity, expired-open-validity,
never-surfaced and code-drift checks raise a `TypeError`. The run exited 1 and lost the whole
report, including the `parse-error` that `sidegraph-verify` found for the same file, and the
hooks that swallow the error silently switched the code-drift line off for every record. The
bad value is now skipped by those checks and reported once, by the model check, so the run
exits 2 with the rest of its findings.

0.10.0

Added

- **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](docs/reference/mcp-tools.mdannotations) and the
[Codex page](docs/integrations/codex.mdthe-auto-reviewer-and-memory-reads).
- **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](docs/reference/mcp-tools.mdretrieve_decisions).

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](docs/reference/mcp-tools.mdfind_entity).
- **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](docs/reference/cli.mdsidegraph-import).

0.9.0

Added

- **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](docs/guides/troubleshooting.mdversion-skew).

- **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](docs/reference/hooks.mdsubagent-briefs).

- **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](docs/reference/hooks.mdsidegraph-subagent-start).

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](docs/reference/hooks.mdre-arm-after-more-work).

- **`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](docs/getting-started/claude-code-setup.md2-add-the-hooks)). 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](docs/reference/hooks.mdsidegraph-pre-tool-use).

- **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 Code) and `Stop` (Codex)
launch `uvx --from` that commit, which `uv` serves from its cache in about 0.2 s, and fall back
to `main` when there is no valid file, as before. A plugin pinned to a tag or commit ignores
the file and launches its own ref. `SessionStart` and the MCP server still run `main`. A release therefore reaches a running session at the next `SessionStart` on the
machine, in any project and either host, not at its next hook call. The Codex `Stop` command is
wrapped in `sh -c '…'` so a fish login shell parses one literal. Codex records trust per hook
definition, so approve the changed `Stop` hook once in `/hooks`; the command no longer changes
between releases. `sidegraph-bootstrap`'s verification finds an entry point inside such a
`sh -c` script. See [troubleshooting](docs/guides/troubleshooting.mdthe-launch-commit-file).

- **`PreToolUse` and `Stop` start about three times faster, and a tool call no longer creates a
store.** `import sidegraph.<anything>` used to load pydantic, ulid and every model, and
`PreToolUse` constructed a `Store`, which walks every record file, once for the touch row and
once for the nudge. The package root now resolves its re-exports on first access;
`PreToolUse` reads and writes `index.db` through one raw SQLite handle, with the same nudge
text, the same one-shot claim and the same touch row; and `Stop` imports `Store` only past its
early exits. On this repository's own store (about 1,500 record files), a subprocess median of
nine: a hook call that does nothing falls from about 83 ms to about 25 ms, an `Edit` from about
96 ms to about 30 ms, a `Read` from about 127 ms to about 35 ms, and the two `Stop` early exits
from about 87 ms to about 34 ms. The handle trusts the index as the last full open left it and
does nothing, printing `{}`, when the index is missing, a store-owned entry is a symlink, the
`schema_version` is not this version's, `retrieval_events` has no `agent` column, or opening it
fails. A `Store` open (a `SessionStart`, a command, a started MCP server) repairs a missing, empty or
older-layout index and refuses a symlinked entry or an unknown `schema_version`, as before; a
running server does not recreate a deleted index. A write that waits out the one-second lock timeout is not
retried in that run. **Behaviour change:** a `Read` or
`Edit` in a project without a store no longer creates `.sidegraph/`, and the nudge path no
longer prints "creating new store"; `SessionStart` and the MCP server still create the store.
A record that arrives mid-session can be missing from the nudge's titles until the next MCP
call. See the [hooks reference](docs/reference/hooks.mdsidegraph-pre-tool-use).

Fixed

- **A finished background agent, an agent-team message or a `!` command's output no longer counts
as a typed prompt.** Claude Code writes each of these into the transcript as a `type: "user"`
line without the `isMeta` flag, so the `Stop` gate counted it: a session with one request and two
background agents passed the two-prompt gate at its first stop, and the capture nudge, which fires
once per session, was spent before there was anything to record. The gate now skips content that
starts `<task-notification>`, `<teammate-message` (the tag carries attributes, and is how a
teammate's own session receives a message), `Another Claude session sent a message` (how the
lead's session receives one) or `<bash-stdout>`, next to the three slash-command wrappers it
already skipped. It also skips a line that newer Claude Code marks with
`origin.kind: "task-notification"` or `promptSource: "system"`, which catches task
notifications written in plain prose. A slash command the
person typed, which starts `<command-message>`, and a `!` command's own `<bash-input>` still
count. Codex rollouts share the prefixes. See the
[hooks reference](docs/reference/hooks.mdsidegraph-stop).
- **A running MCP server sees a record that arrives mid-session.** The server keeps one `Store`,
and a `Store` checks the record files only when it is opened, so after a `git pull`
`get_task_context` and `retrieve_decisions` kept answering from the index as it stood until the
server restarted. The `PreToolUse` hook, which opened a `Store` on every call, was what
refreshed it, by accident. Every tool call now first re-checks the record files: a digest walk
(about 8 ms on 1,500 files), and a rebuild only when something changed. A filesystem error during
that check, such as a checkout deleting a record file mid-walk, does not fail the call: the tool
answers from the previous index and one line goes to stderr.
- **One record file that does not parse no longer switches memory off.** A merge that left
conflict markers in a decision, entity, fact or bindings file made the index rebuild raise, so
the store could not be opened: every hook fell back to "cannot open its store", and the MCP
server served nothing. The rebuild now skips such a file, whether it is invalid JSON, invalid
UTF-8, a wrong shape, rejected by its model or impossible to serialise, lists it as
`parse-error`, and loads the rest. The usual notice names the first such file, and
`sidegraph-verify` reports each of them; a file that fails validation no longer hides its
archived copy. A bindings file is skipped whole, so a half-valid file never leaves half of its
anchors in the index, and its record stays retrievable without them. A file that cannot be
opened at all (a permission or I/O error) still fails the open.
- **An archive line that cannot be read costs that line, not the store.** A line of an archive
segment that is not valid UTF-8 or JSON, or whose decision or domain fails its model, is skipped
with a warning in the rebuild, the rest of the segment loads, and the segment is listed as
`bad-archive-segment`. `sidegraph-verify` reports each such line with its segment and line
number. `sidegraph-compact` keeps the strict rule: it refuses to run over a segment it cannot
fully read and names the segment and the line (`--dry-run` included, with exit code 1 and no
traceback), and a hot file the rebuild left out no longer makes `--dry-run` fail with a bare
`JSONDecodeError`.
- **A file the rebuild left out is never overwritten.** With the store open again, a write that
recomputes a record's bindings from the index (`add_anchors`, `add_binding`) would have replaced
a conflicted bindings file with only the new anchor. All six canonical writers now refuse to
replace a listed file, with an error that names it. `add_anchors` checks first, so its refusal
leaves no orphan entity file behind. `supersede_decision` and `supersede_fact` refuse to inherit
anchors from a predecessor whose bindings file is skipped, since the successor would silently
start with none. `sidegraph-doctor` now says a skipped bindings file is skipped (unreadable),
not absent or empty. While an entity file is skipped, an anchor to the
same name mints a second entity, which `sidegraph-doctor`'s `duplicate-entity` check shows once
the file is fixed.
- **The skip warning no longer says "remove".** Removing a record file breaks the append-only
store, and removing an archive segment destroys every archived record in it. The warning now
says to restore the file with git or fix it by hand, and never to delete a segment. The
`store-files-skipped` notice says the same when the first listed file is an archive segment:
its lines that could not be read are left out, restore it with git, never delete it.
- **`sidegraph-verify` and `sidegraph-doctor` report what the rebuild leaves out.** They read an
archive segment as one string, so a line of invalid UTF-8 made them exit 1 with "store not
readable" and no file, and a line whose decision or domain fails its model was not reported at
all. They now split a segment into lines as bytes and report each bad line as
`bad-archive-segment`, with its segment and line number. A record file of invalid UTF-8, one
nested past the recursion limit and one that validates but cannot be serialised (a lone
surrogate) are `parse-error`s naming the file, where the first two exited 1 and the third
reported clean. A title holding U+2028 or U+0085 no longer gives a false `bad-archive-segment`.
- **Compacting a decision whose title holds U+2028 or U+0085 no longer leaves a store that will
not open.** The archive reader split a segment with `str.splitlines`, which breaks a line at
those characters (the segment holds them raw), so the next rebuild raised "corrupt archive
segment". A segment is now split as bytes, at `\n` and `\r` only.

Page 1 of 3

© 2026 Safety CLI Cybersecurity Inc. All Rights Reserved.