sidegraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sidegraph/server.py ADDED
@@ -0,0 +1,2608 @@
1
+ """The decision MCP — the tools agents call (Stage 1).
2
+
3
+ Engine-independent: this server exposes the owned store over MCP so decisions can be added,
4
+ superseded, and retrieved with no engine present yet. Anchor resolution against Graphify
5
+ arrives in Stage 3; retrieval merge + budgeting in Stage 4.
6
+
7
+ Run with ``uv run sidegraph-mcp`` (stdio transport).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import json
14
+ import os
15
+ import threading
16
+ from datetime import UTC, datetime, timedelta
17
+ from importlib.metadata import PackageNotFoundError
18
+ from importlib.metadata import version as _pkg_version
19
+ from pathlib import Path
20
+ from typing import Literal, cast, get_args
21
+
22
+ from fastmcp import FastMCP
23
+
24
+ from .anchoring import entity_summaries as _entity_summaries
25
+ from .anchoring import orphan_reason, resolve_and_bind
26
+ from .capture import (
27
+ AnchorDraft,
28
+ RatifyPolicy,
29
+ _bind_orphaned,
30
+ _capture_commit,
31
+ _resolves_to_live_decision,
32
+ _session_id_fallback,
33
+ format_communities_sample,
34
+ format_fact_proposal,
35
+ format_path_prefixes,
36
+ format_proposal,
37
+ format_seed_anchors_sample,
38
+ parse_ratify_policy,
39
+ propose,
40
+ propose_facts,
41
+ redact,
42
+ )
43
+ from .capture import propose_domains as _propose_domain_drafts
44
+ from .config import TELEMETRY_SESSION_KEY, resolve_store_path
45
+ from .domains import DEFAULT_CANDIDATE_LIMIT, collect_domain_candidates, community_group_path
46
+ from .engine.reader import GraphifyReader
47
+ from .retrieval import TOC_CACHE_KEY, RetrievalBudget, Seed, build_toc, proposal_surfaces
48
+ from .retrieval import drill_down as _drill_down
49
+ from .retrieval import get_task_context as _retrieve
50
+ from .retrieval import query_structure as _query_structure
51
+ from .schema import (
52
+ AnchorBinding,
53
+ Decision,
54
+ DecisionKind,
55
+ DecisionStatus,
56
+ Descriptor,
57
+ Domain,
58
+ DomainStatus,
59
+ Fact,
60
+ Provenance,
61
+ Relation,
62
+ slugify,
63
+ )
64
+ from .store import VOLATILE_STALE_KEY, Store
65
+ from .sync import activate_accepted_domain, maybe_sync, report_as_dict, sync
66
+ from .verify import verify_snapshot
67
+
68
+
69
+ def _server_version() -> str:
70
+ """Sidegraph's own installed package version, for FastMCP's ``serverInfo.version`` (Gate-5
71
+ finding N1) — the ``initialize`` handshake previously fell through to fastmcp's default
72
+ (its OWN package version, not ours), which misidentified sidegraph to any MCP client that
73
+ surfaces server version. ``PackageNotFoundError`` (e.g. running from a source checkout
74
+ with no installed distribution metadata) falls back to a clearly-synthetic placeholder
75
+ rather than crashing server startup over a cosmetic field."""
76
+ try:
77
+ return _pkg_version("sidegraph")
78
+ except PackageNotFoundError:
79
+ return "0.0.0-dev"
80
+
81
+
82
+ mcp = FastMCP("sidegraph", version=_server_version())
83
+
84
+ # One process-wide store, created LAZILY on first use -- never as a side effect of merely
85
+ # importing this module. A bare eager `_store = Store(...)` at import time used to
86
+ # materialize a stray store (e.g. "sidegraph.db" in whatever the current working directory
87
+ # happened to be) just from `import sidegraph.server`, which is exactly the kind of
88
+ # import-time side effect a library module must not have. Path resolution is shared with
89
+ # the CLI and the Claude Code hooks via config.resolve_store_path (SIDEGRAPH_DIR primary,
90
+ # SIDEGRAPH_DB honored for back-compat, default ".sidegraph") -- see
91
+ # docs/reference/configuration.md.
92
+ _store: Store | None = None
93
+
94
+ # Guards `_get_store()`'s memoization (review Important-2b): fastmcp 3 dispatches sync
95
+ # @mcp.tool calls onto worker threads (see store.py's own threading note), so a cold-start
96
+ # process can have several requests race the check-then-set below at once. A bare
97
+ # `if _store is None: _store = Store(...)` is not atomic -- two threads can both observe
98
+ # None, both construct a Store (leaking the loser's open sqlite connection), and callers
99
+ # end up disagreeing on which instance is "the" store.
100
+ _store_lock = threading.Lock()
101
+
102
+
103
+ def _get_store() -> Store:
104
+ """Lazily create and memoize the process-wide Store on first actual use.
105
+
106
+ Double-checked locking: the lock is only taken on the (rare) cold-start race window:
107
+ once `_store` is set, every later call reads it lock-free.
108
+ """
109
+ global _store
110
+ if _store is None:
111
+ with _store_lock:
112
+ if _store is None:
113
+ _store = Store(resolve_store_path())
114
+ return _store
115
+
116
+
117
+ def _graph_path() -> str:
118
+ """The graph path ``_load_reader()`` resolves and reads from -- factored out so a
119
+ caller that gets back ``None`` (a bad/missing graph) can still explain WHERE it looked
120
+ (``sync_anchors``'s unreadable-graph error), since ``_load_reader()`` itself degrades
121
+ a bad path to a bare ``None`` with no path attached."""
122
+ return os.environ.get("SIDEGRAPH_GRAPH", "graphify-out/graph.json")
123
+
124
+
125
+ def _load_reader() -> GraphifyReader | None:
126
+ """Best-effort reader over $SIDEGRAPH_GRAPH (or graphify-out/graph.json). None if absent."""
127
+ try:
128
+ return GraphifyReader(_graph_path())
129
+ except Exception:
130
+ return None
131
+
132
+
133
+ # The only legal AnchorBinding.relation values (Relation is a Literal, not an enum) — used
134
+ # to validate `anchors[i]["relation"]` BEFORE any store write (see _validate_anchor_relations).
135
+ _VALID_RELATIONS = frozenset(get_args(Relation))
136
+
137
+
138
+ def _coerce_tags(tags: list[str] | str | None) -> list[str]:
139
+ """Liberal-input tags: agents routinely pass a bare (often comma-separated) string on
140
+ the first try — accept it instead of failing schema validation and forcing a retry
141
+ (live finding, manual test 2026-07-08). A string splits on commas; blanks drop."""
142
+ if tags is None:
143
+ return []
144
+ if isinstance(tags, str):
145
+ return [part.strip() for part in tags.split(",") if part.strip()]
146
+ return list(tags)
147
+
148
+
149
+ def _redact_fields(*fields: str | None) -> tuple[list[str | None], int]:
150
+ """Scrub every text field through ``capture.redact`` (``None`` passes through).
151
+
152
+ The direct write paths (``add_decision``/``supersede_decision``) used to commit their
153
+ text verbatim while the propose/import pipelines redacted first — a gap against the
154
+ redact-first rule for a repo-committed store (2026-07-10 audit). Returns
155
+ ``(clean_fields, total_replacement_count)``.
156
+ """
157
+ out: list[str | None] = []
158
+ total = 0
159
+ for field in fields:
160
+ if field is None:
161
+ out.append(None)
162
+ else:
163
+ clean, n = redact(field)
164
+ out.append(clean)
165
+ total += n
166
+ return out, total
167
+
168
+
169
+ def _validate_anchor_relations(anchors: list[dict] | None) -> None:
170
+ """Raise before ANY write when an anchor's `relation` isn't a legal value (M2 review
171
+ fold-in): without this, an invalid relation only surfaced when ``resolve_and_bind``
172
+ constructed the offending ``AnchorBinding`` — by which point the decision row (and any
173
+ earlier anchors in the same call) were already written, leaving a half-anchored
174
+ decision behind. Validating the whole batch up front keeps the write atomic: either
175
+ every anchor is legal and the decision writes with all of them, or nothing writes at
176
+ all.
177
+ """
178
+ for raw in anchors or []:
179
+ relation = raw.get("relation")
180
+ if relation is not None and relation not in _VALID_RELATIONS:
181
+ raise ValueError(
182
+ f"invalid relation {relation!r} for anchor {raw.get('name')!r}: must be "
183
+ f"one of {sorted(_VALID_RELATIONS)}"
184
+ )
185
+
186
+
187
+ def _require_fact_reachability(store, anchors: list[dict] | None, supports: list[str]) -> None:
188
+ """Anchorless fact-write gate (design D8): a fact with no anchors must have at least one
189
+ ``supports`` id resolving to a LIVE (accepted/proposed) decision, or it is unreachable the
190
+ moment it lands — exactly the shape doctor's tightened ``dangling-record`` check (D4/D5/
191
+ D6) would flag. A fact WITH an anchor is untouched.
192
+
193
+ Raise before ANY write, same discipline as ``_validate_anchor_relations`` above. Shared by
194
+ ``_add_fact_impl`` and ``_supersede_fact_impl``'s no-anchors path — the two human-asked
195
+ fact-writing entry points — mirroring ``capture.py``'s own anchorless-fact gate for the
196
+ agent-initiated path (``_resolves_to_live_decision``, imported from there, is the one
197
+ shared definition of "live" all three write paths use).
198
+ """
199
+ if anchors:
200
+ return
201
+ if not _resolves_to_live_decision(store, supports):
202
+ raise ValueError(
203
+ "anchorless fact has no live supporting decision — add an anchor, or "
204
+ "re-point supports at the successor of a superseded/rejected/deprecated one"
205
+ )
206
+
207
+
208
+ def _add_decision_impl(
209
+ store,
210
+ reader,
211
+ title: str,
212
+ kind: str,
213
+ context: str,
214
+ choice: str,
215
+ rejected: str | None = None,
216
+ consequences: str | None = None,
217
+ author: str | None = None,
218
+ session_id: str | None = None,
219
+ anchors: list[dict] | None = None,
220
+ initiative: str | None = None,
221
+ tags: list[str] | str | None = None,
222
+ layer: str | None = None,
223
+ ) -> dict:
224
+ """Testable core: redact, write the decision, then best-effort multi-anchor it (+ tag it)."""
225
+ _validate_anchor_relations(anchors)
226
+ # title/context/choice are required (non-Optional) here, so they're redacted directly
227
+ # (keeps them typed `str`, not the `str | None` `_redact_fields` returns uniformly);
228
+ # only the genuinely optional pair goes through `_redact_fields`.
229
+ title, n1 = redact(title)
230
+ context, n2 = redact(context)
231
+ choice, n3 = redact(choice)
232
+ (rejected, consequences), n4 = _redact_fields(rejected, consequences)
233
+ redactions = n1 + n2 + n3 + n4
234
+ graph_version = reader.graph_version() if reader is not None else None
235
+ # I1 (R1 improvement wave §1): the D7.3 marker fallback, extended to the "add" pair --
236
+ # same rule _supersede_decision_impl already applies (see its own comment): explicit
237
+ # param wins, fallback only fills absence. No design rationale on record for why a
238
+ # direct add made mid-session deserved worse attribution than a propose.
239
+ if session_id is None:
240
+ session_id = _session_id_fallback(store)
241
+ decision = Decision(
242
+ title=title,
243
+ kind=DecisionKind(kind),
244
+ status=DecisionStatus.ACCEPTED,
245
+ context=context,
246
+ choice=choice,
247
+ rejected=rejected,
248
+ consequences=consequences,
249
+ # `layer` is a free-form MCP-tool string; Decision.layer is the strict Literal —
250
+ # pydantic validates/rejects at construction (same as `DecisionKind(kind)` above),
251
+ # this cast only satisfies the static type, it changes no runtime behavior.
252
+ layer=cast(Literal["business", "technical"] | None, layer),
253
+ valid_from=datetime.now(UTC),
254
+ provenance=Provenance(
255
+ source="human",
256
+ author=author,
257
+ session_id=session_id,
258
+ graph_version=graph_version,
259
+ # П0 (git-bindings design, Blocker 1): the "add" pair's commit stamp, same
260
+ # helper _supersede_decision_impl already uses -- mechanical I1 twin.
261
+ commit=_capture_commit(store),
262
+ ),
263
+ )
264
+ store.add_decision(decision)
265
+
266
+ anchors_skipped, anchors_orphaned = _resolve_anchors(
267
+ decision.id, anchors, reader, store, initiative=initiative
268
+ )
269
+ for tag in _coerce_tags(tags):
270
+ scrubbed, n = redact(tag)
271
+ redactions += n
272
+ slug = slugify(scrubbed)
273
+ # Same rule as the propose pipeline: a tag whose entire text WAS the secret
274
+ # slugifies to exactly "redacted" -- skip it, never mint a meaningless
275
+ # `tag:redacted` entity.
276
+ if not slug or slug == "redacted":
277
+ continue
278
+ tag_entity = store.get_or_create_abstract_entity(f"tag:{slug}")
279
+ store.add_binding(
280
+ AnchorBinding(
281
+ record_id=decision.id,
282
+ entity_id=tag_entity.entity_id,
283
+ tier=0,
284
+ )
285
+ )
286
+ bindings = store.bindings_for_record(decision.id)
287
+ return {
288
+ "id": decision.id,
289
+ "status": decision.status.value,
290
+ "bindings": len(bindings),
291
+ "entities": _entity_summaries(store, bindings),
292
+ "anchors_skipped": anchors_skipped,
293
+ "anchors_orphaned": anchors_orphaned,
294
+ "redactions": redactions,
295
+ }
296
+
297
+
298
+ def _resolve_anchors(
299
+ record_id: str,
300
+ anchors: list[dict] | None,
301
+ reader,
302
+ store,
303
+ initiative: str | None = None,
304
+ ) -> tuple[list[dict], list[dict]]:
305
+ """Resolve+bind every anchor ref, returning ``(ambiguous, orphaned)`` as feedback.
306
+
307
+ ``ambiguous`` (Gate-5 finding S3) is
308
+ ``[{"name", "reason": "ambiguous", "candidates": [...capped 5]}]`` — matched more than
309
+ one node, so NO leaf was created.
310
+
311
+ ``orphaned`` is the entity summary of every leaf bound for an anchor that resolved to
312
+ NOTHING. That leaf IS written (``anchoring.resolve_and_bind``: "created when resolved
313
+ or unresolved"), deliberately — but it is dead on arrival: ``valid_decisions_for_entity``
314
+ skips orphaned bindings, so no retrieval path, no ``drill_down`` and no PreToolUse nudge
315
+ can ever deliver the record through it, and no Tier-1 community fallback is created
316
+ either (there is no resolved node to take a community from). Reporting it is the whole
317
+ point: an unresolved anchor used to come back as ``bindings: 1``, an entity summary and
318
+ an empty ``anchors_skipped`` — indistinguishable from success. Measured cost of that
319
+ silence: 29% of Tier-2 bindings orphaned-at-birth on the airflow corpus against 0-4%
320
+ everywhere else (``design/testing/2026-08-03-delivery-gap-remeasure.md``).
321
+
322
+ The bucket names mirror ``add_anchors``, which already reports
323
+ ``bound``/``orphaned``/``ambiguous`` separately — one vocabulary for one fact.
324
+
325
+ ``resolve_and_bind`` already carries the ``reader.resolve()`` outcome on its return
326
+ value (``anchoring.AnchorResolution``), so this never re-resolves a ref just to learn
327
+ why no leaf binding was created. No-op (``([], [])``) when there's no reader — anchoring
328
+ is best-effort throughout this module, and neither "ambiguous" nor "orphaned" is
329
+ meaningful with no graph to resolve against.
330
+ """
331
+ skipped: list[dict] = []
332
+ orphaned: list[dict] = []
333
+ if anchors and reader is not None:
334
+ for raw in anchors:
335
+ name = raw.get("name")
336
+ if not name:
337
+ continue
338
+ ref = Descriptor(name=name, file_path=raw.get("file_path"))
339
+ result = resolve_and_bind(
340
+ record_id,
341
+ ref,
342
+ reader,
343
+ store,
344
+ initiative=initiative,
345
+ relation=raw.get("relation"),
346
+ )
347
+ if result.status == "ambiguous":
348
+ skipped.append(
349
+ {
350
+ "name": name,
351
+ "reason": "ambiguous",
352
+ "candidates": result.candidates[:5],
353
+ }
354
+ )
355
+ elif result.status == "unresolved":
356
+ # Summarize only the leaves THIS anchor just produced, never the record's
357
+ # whole binding set: a record can carry earlier live anchors, and a bucket
358
+ # that reported those as orphaned would be worse than no bucket at all.
359
+ reason = orphan_reason(ref, reader)
360
+ orphaned.extend(
361
+ {**s, "reason": reason}
362
+ for s in _entity_summaries(store, [b for b in result if b.tier == 2])
363
+ )
364
+ return skipped, orphaned
365
+
366
+
367
+ @mcp.tool
368
+ def add_decision(
369
+ title: str,
370
+ kind: str,
371
+ context: str,
372
+ choice: str,
373
+ rejected: str | None = None,
374
+ consequences: str | None = None,
375
+ author: str | None = None,
376
+ session_id: str | None = None,
377
+ anchors: list[dict] | None = None,
378
+ initiative: str | None = None,
379
+ tags: list[str] | str | None = None,
380
+ layer: str | None = None,
381
+ ) -> dict:
382
+ """Append a decision (ADR / lesson / constraint / gotcha) to the store.
383
+
384
+ ``rejected`` is what was tried and abandoned, and why. ``anchors`` is a list of
385
+ ``{"name": ..., "file_path": ..., "relation": ...}`` refs to the code entities the
386
+ decision is about (``relation`` optional: creates|modifies|affects|deprecates|
387
+ considered, defaults to "affects"); each is resolved against the current Graphify graph
388
+ and multi-anchored (leaf + domain/community [+ initiative]). Anchoring is best-effort:
389
+ with no graph present, the decision still writes.
390
+
391
+ Every text field (title/context/choice/rejected/consequences, and tag text before
392
+ slugification) is redacted first — same secret patterns as the propose/import
393
+ pipelines; the scrubbed text is the only text that reaches the repo-committed store.
394
+
395
+ ``tags`` are free-form labels — a bare comma-separated string is accepted too —
396
+ slugified (lowercase, spaces->'-', ``[a-z0-9-]`` only) into durable ``tag:<slug>``
397
+ entities (tier-0, many-to-many — a decision can carry several, and
398
+ ``get_entity_history`` finds it via any of them, same as an initiative).
399
+ ``layer`` optionally marks the decision "business" or "technical" — a filter axis for
400
+ mixed corpora.
401
+
402
+ Returns ``{"id", "status", "bindings", "entities", "anchors_skipped", "redactions"}``
403
+ (``redactions`` = secret replacements made across all text fields) — ``entities`` is
404
+ ``[{"entity_id", "canonical_name", "tier"}, ...]``, one per binding created, so a caller
405
+ can chain straight into ``find_entity``/``get_entity_history`` without touching the store.
406
+ ``anchors_skipped`` is ``[{"name", "reason": "ambiguous", "candidates"}, ...]`` — the
407
+ anchors whose name matched more than one graph node (candidates capped at 5), so no
408
+ precise Tier-2 leaf was created for them; empty when every anchor resolved cleanly or no
409
+ graph is present.
410
+ """
411
+ return _add_decision_impl(
412
+ _get_store(),
413
+ _load_reader(),
414
+ title,
415
+ kind,
416
+ context,
417
+ choice,
418
+ rejected=rejected,
419
+ consequences=consequences,
420
+ author=author,
421
+ session_id=session_id,
422
+ anchors=anchors,
423
+ initiative=initiative,
424
+ tags=tags,
425
+ layer=layer,
426
+ )
427
+
428
+
429
+ def _supersede_decision_impl(
430
+ store,
431
+ reader,
432
+ old_decision_id: str,
433
+ title: str,
434
+ kind: str,
435
+ context: str,
436
+ choice: str,
437
+ rejected: str | None = None,
438
+ consequences: str | None = None,
439
+ anchors: list[dict] | None = None,
440
+ session_id: str | None = None,
441
+ author: str | None = None,
442
+ source: str = "human",
443
+ ) -> dict:
444
+ """Testable core: write the successor, then anchor it (explicit anchors, or inherit).
445
+
446
+ See CLAUDE.md gap notes: an unanchored successor was invisible to task-seeded retrieval
447
+ exactly where a reversal matters most. Two paths, never both:
448
+
449
+ - ``anchors`` given -> resolve_and_bind the successor to ONLY those refs (same as
450
+ add_decision; best-effort, skipped if no reader). Reuses ``_resolve_anchors`` so this
451
+ path reports the same per-anchor ``anchors_skipped`` feedback ``add_decision`` does
452
+ (Gate finding: this used to discard ``resolve_and_bind``'s per-anchor result outright,
453
+ so an ambiguous explicit anchor on a supersede silently produced no Tier-2 leaf and no
454
+ feedback about why).
455
+ - ``anchors`` omitted -> copy the predecessor's existing bindings verbatim (same
456
+ entity_id/tier/weight/relation/status) onto the successor. This is the obviously-right
457
+ default: a reversal concerns the same entities the original decision did, so retrieval
458
+ should find the successor everywhere it found the predecessor. Nothing is "skipped" on
459
+ this path (inheritance never resolves against the graph), so ``anchors_skipped`` is
460
+ always ``[]`` here.
461
+
462
+ ``session_id``/``author``/``source`` (design D6, all optional/additive): stamped onto
463
+ the successor's ``Provenance`` the same way ``propose`` stamps a captured decision's.
464
+ ``source`` defaults to ``"human"`` — this tool's own historical hardcoded value, so an
465
+ existing caller that never passes it keeps stamping exactly what it always has; an
466
+ agent-initiated caller (e.g. a future supersede-from-neighbors flow) passes
467
+ ``source="agent"`` instead. ``graph_version``/``commit`` are stamped the way ``propose``
468
+ stamps them too — ``graph_version`` from the reader when present, ``commit`` via the
469
+ same best-effort ``git rev-parse HEAD`` (:func:`sidegraph.capture._capture_commit`).
470
+ """
471
+ # See _add_decision_impl: title/context/choice are required, redacted directly (stays
472
+ # `str`); only the optional pair goes through `_redact_fields` (returns `str | None`).
473
+ title, n1 = redact(title)
474
+ context, n2 = redact(context)
475
+ choice, n3 = redact(choice)
476
+ (rejected, consequences), n4 = _redact_fields(rejected, consequences)
477
+ redactions = n1 + n2 + n3 + n4
478
+ graph_version = reader.graph_version() if reader is not None else None
479
+ # D7.3, extended post-E9b: the marker fallback lived in _propose_one only, so every
480
+ # supersede-path successor landed session_id=None even mid-session (measured in the
481
+ # E9b run). Same rule as propose: explicit param wins, fallback only fills absence.
482
+ if session_id is None:
483
+ session_id = _session_id_fallback(store)
484
+ replacement = Decision(
485
+ title=title,
486
+ kind=DecisionKind(kind),
487
+ status=DecisionStatus.ACCEPTED,
488
+ context=context,
489
+ choice=choice,
490
+ rejected=rejected,
491
+ consequences=consequences,
492
+ valid_from=datetime.now(UTC),
493
+ supersedes=old_decision_id,
494
+ provenance=Provenance(
495
+ source=source,
496
+ author=author,
497
+ session_id=session_id,
498
+ graph_version=graph_version,
499
+ commit=_capture_commit(store),
500
+ ),
501
+ )
502
+ store.add_decision(replacement)
503
+
504
+ if anchors:
505
+ anchors_skipped, anchors_orphaned = _resolve_anchors(replacement.id, anchors, reader, store)
506
+ else:
507
+ # Inheritance resolves nothing against the graph, so neither bucket can speak here
508
+ # -- including when a predecessor binding being copied is ITSELF already orphaned.
509
+ # Surfacing inherited orphans is a real gap (see docs/guides/surviving-refactors.md
510
+ # on omitting `anchors`), but it is a different question from "the anchor you just
511
+ # passed did not resolve", and answering it here would report a state this call
512
+ # neither created nor could fix.
513
+ anchors_skipped, anchors_orphaned = [], []
514
+ for b in store.bindings_for_record(old_decision_id):
515
+ store.add_binding(
516
+ AnchorBinding(
517
+ record_id=replacement.id,
518
+ entity_id=b.entity_id,
519
+ tier=b.tier,
520
+ weight=b.weight,
521
+ status=b.status,
522
+ relation=b.relation,
523
+ )
524
+ )
525
+
526
+ bindings = store.bindings_for_record(replacement.id)
527
+ return {
528
+ "id": replacement.id,
529
+ "supersedes": old_decision_id,
530
+ "bindings": len(bindings),
531
+ "entities": _entity_summaries(store, bindings),
532
+ "anchors_skipped": anchors_skipped,
533
+ "anchors_orphaned": anchors_orphaned,
534
+ "redactions": redactions,
535
+ }
536
+
537
+
538
+ @mcp.tool
539
+ def supersede_decision(
540
+ old_decision_id: str,
541
+ title: str,
542
+ kind: str,
543
+ context: str,
544
+ choice: str,
545
+ rejected: str | None = None,
546
+ consequences: str | None = None,
547
+ anchors: list[dict] | None = None,
548
+ session_id: str | None = None,
549
+ author: str | None = None,
550
+ source: str = "human",
551
+ ) -> dict:
552
+ """Reverse a decision: close the old one and append a replacement that supersedes it.
553
+
554
+ Call this when work has made a recorded decision false, too broad, or reversed — the
555
+ situations retrieval renders as ``(id: ...)`` lines and ``propose_decisions`` reports as
556
+ ``neighbors``. Never leave a new record contradicting a live old one.
557
+
558
+ The predecessor is not deleted — it stays retrievable as "tried before, abandoned".
559
+ Every text field is redacted first, exactly like ``add_decision``'s.
560
+
561
+ Anchoring: pass ``anchors`` (same shape as ``add_decision``'s — a list of
562
+ ``{"name": ..., "file_path": ...}`` refs) to resolve and bind the successor to ONLY
563
+ those refs. Omit ``anchors`` (the default) to INHERIT the predecessor's bindings
564
+ verbatim instead — the successor concerns the same entities the original decision did,
565
+ so it should be reachable via task-seeded retrieval everywhere the predecessor was.
566
+ Passing ``anchors`` replaces inheritance; it never adds to it.
567
+
568
+ ``session_id``/``author`` (optional) and ``source`` (default ``"human"``, this tool's
569
+ historical hardcoded value — pass ``"agent"`` when an agent calls this itself, e.g. off
570
+ a ``neighbors`` or ``(id: ...)`` hint) are stamped onto the successor's provenance,
571
+ alongside ``graph_version`` and a best-effort capture-time ``commit`` (same fields
572
+ ``propose_decisions`` stamps).
573
+
574
+ Returns ``{"id", "supersedes", "bindings", "entities", "anchors_skipped",
575
+ "redactions"}`` — ``anchors_skipped`` is ``[{"name", "reason": "ambiguous",
576
+ "candidates"}, ...]``, the same per-anchor feedback ``add_decision`` returns
577
+ (candidates capped at 5): populated
578
+ only on the explicit-``anchors`` path (an anchor whose name matched more than one graph
579
+ node got no precise Tier-2 leaf), always ``[]`` when ``anchors`` is omitted since
580
+ inheritance never resolves against the graph.
581
+ """
582
+ return _supersede_decision_impl(
583
+ _get_store(),
584
+ _load_reader(),
585
+ old_decision_id,
586
+ title,
587
+ kind,
588
+ context,
589
+ choice,
590
+ rejected=rejected,
591
+ consequences=consequences,
592
+ anchors=anchors,
593
+ session_id=session_id,
594
+ author=author,
595
+ source=source,
596
+ )
597
+
598
+
599
+ def _bind_fact_anchors(
600
+ fact_id: str,
601
+ anchors: list[dict] | None,
602
+ reader,
603
+ store,
604
+ ) -> tuple[list[dict], list[dict]]:
605
+ """Anchor a fact's explicit ``anchors``, returning ``(ambiguous, orphaned)`` — the same
606
+ two buckets ``_resolve_anchors`` gives ``add_decision``.
607
+
608
+ With a reader, this IS ``_resolve_anchors``. With no reader, bind an orphaned Tier-2
609
+ leaf per anchor instead of ``_resolve_anchors``'s no-op — facts must not repeat
610
+ ``add_decision``'s no-graph anchors-silently-dropped asymmetry
611
+ (design/superpowers/specs/2026-07-10-facts-layer-design.md) — and report those leaves
612
+ in the orphaned bucket too: a graph-less run produces a dead anchor exactly as an
613
+ unresolved name does, and the caller has the same reason to know. Shared by
614
+ ``_add_fact_impl`` and ``_supersede_fact_impl``'s explicit-anchors path so both give the
615
+ same guarantee.
616
+ """
617
+ if not anchors:
618
+ return [], []
619
+ if reader is not None:
620
+ return _resolve_anchors(fact_id, anchors, reader, store)
621
+ orphaned: list[dict] = []
622
+ for raw in anchors:
623
+ if not raw.get("name"):
624
+ continue
625
+ before = {b.entity_id for b in store.bindings_for_record(fact_id)}
626
+ _bind_orphaned(
627
+ fact_id,
628
+ AnchorDraft.model_validate(raw),
629
+ store,
630
+ relation=raw.get("relation"),
631
+ )
632
+ orphaned.extend(
633
+ _entity_summaries(
634
+ store,
635
+ [
636
+ b
637
+ for b in store.bindings_for_record(fact_id)
638
+ if b.tier == 2 and b.entity_id not in before
639
+ ],
640
+ )
641
+ )
642
+ return [], orphaned
643
+
644
+
645
+ def _add_fact_impl(
646
+ store,
647
+ reader,
648
+ statement: str,
649
+ source: str,
650
+ supports: list[str] | None = None,
651
+ anchors: list[dict] | None = None,
652
+ author: str | None = None,
653
+ session_id: str | None = None,
654
+ ) -> dict:
655
+ """Testable core: redact, write the fact, then best-effort multi-anchor it.
656
+
657
+ Human-asked path: lands ``status=accepted`` directly (the asking human was the gate —
658
+ same rationale as ``add_decision``, no ``proposed``-then-ratify hop) with
659
+ ``provenance.source="human"``.
660
+
661
+ This is the PRIMARY fact-writing path (what the ``record-fact`` skill drives) and, before
662
+ design D8, had no reachability gate at all: a bare ``add_fact(statement, source)`` — no
663
+ anchors, no supports — wrote a record no retrieval surface could ever find, and
664
+ ``add_fact(..., supports=[<terminal id>])`` wrote one born flagged by doctor's tightened
665
+ ``dangling-record`` check. ``_require_fact_reachability`` closes both.
666
+ """
667
+ _validate_anchor_relations(anchors)
668
+ _require_fact_reachability(store, anchors, supports or [])
669
+ # statement/source are both required (non-Optional) — redact directly, same reasoning
670
+ # as _add_decision_impl (keeps them typed `str`, not `_redact_fields`'s `str | None`).
671
+ statement, n1 = redact(statement)
672
+ source, n2 = redact(source)
673
+ redactions = n1 + n2
674
+ graph_version = reader.graph_version() if reader is not None else None
675
+ # I1 (R1 improvement wave §1): same "add" pair extension as _add_decision_impl's.
676
+ if session_id is None:
677
+ session_id = _session_id_fallback(store)
678
+ fact = Fact(
679
+ statement=statement,
680
+ source=source,
681
+ supports=supports or [],
682
+ status=DecisionStatus.ACCEPTED,
683
+ valid_from=datetime.now(UTC),
684
+ provenance=Provenance(
685
+ source="human",
686
+ author=author,
687
+ session_id=session_id,
688
+ graph_version=graph_version,
689
+ # П0 (git-bindings design, Blocker 1): same "add" pair extension as
690
+ # _add_decision_impl's.
691
+ commit=_capture_commit(store),
692
+ ),
693
+ )
694
+ store.add_fact(fact)
695
+
696
+ anchors_skipped, anchors_orphaned = _bind_fact_anchors(fact.id, anchors, reader, store)
697
+ bindings = store.bindings_for_record(fact.id)
698
+ return {
699
+ "id": fact.id,
700
+ "statement": fact.statement,
701
+ "status": fact.status.value,
702
+ "redactions": redactions,
703
+ "entities": _entity_summaries(store, bindings),
704
+ "anchors_skipped": anchors_skipped,
705
+ "anchors_orphaned": anchors_orphaned,
706
+ }
707
+
708
+
709
+ @mcp.tool
710
+ def add_fact(
711
+ statement: str,
712
+ source: str,
713
+ supports: list[str] | None = None,
714
+ anchors: list[dict] | None = None,
715
+ author: str | None = None,
716
+ session_id: str | None = None,
717
+ ) -> dict:
718
+ """Append a hard-won fact — human-asked, lands ``accepted`` immediately (no ratify hop).
719
+
720
+ Only facts the code graph cannot derive belong here: empirics (benchmarks, observed
721
+ behavior), external constraints (API limits, library capabilities), trial-learned
722
+ knowledge — never 'the code does X'.
723
+
724
+ ``statement`` is the fact itself (1-2 sentences, hard-compact); ``source`` is the
725
+ epistemics — how we know ("benchmark run 2026-07-09", "httpx docs"). ``supports`` is a
726
+ list of decision ids this fact informed (each must already exist — raises
727
+ ``ValueError`` otherwise). ``anchors`` is the same ``{"name", "file_path", "relation"?}``
728
+ ref shape ``add_decision`` takes; resolved against the current Graphify graph and bound
729
+ when a graph is present. With no graph, an anchor still gets an ORPHANED Tier-2 leaf
730
+ (unlike ``add_decision``, which silently skips anchors with no reader) — a fact must
731
+ never write unreachable, so the binding heals once a graph exists.
732
+
733
+ Every text field (statement/source) is redacted first, same secret patterns as
734
+ ``add_decision``'s.
735
+
736
+ Returns ``{"id", "statement", "status", "redactions", "entities", "anchors_skipped"}``
737
+ — ``entities`` is ``[{"entity_id", "canonical_name", "tier"}, ...]``, one per binding
738
+ created; ``anchors_skipped`` is ``[{"name", "reason": "ambiguous", "candidates"}, ...]``
739
+ (candidates capped at 5) — populated only when a graph is present and an anchor's name
740
+ matched more than one node, since there is nothing to be ambiguous against otherwise.
741
+ """
742
+ return _add_fact_impl(
743
+ _get_store(),
744
+ _load_reader(),
745
+ statement,
746
+ source,
747
+ supports=supports,
748
+ anchors=anchors,
749
+ author=author,
750
+ session_id=session_id,
751
+ )
752
+
753
+
754
+ def _supersede_fact_impl(
755
+ store,
756
+ reader,
757
+ old_fact_id: str,
758
+ statement: str,
759
+ source: str,
760
+ supports: list[str] | None = None,
761
+ anchors: list[dict] | None = None,
762
+ session_id: str | None = None,
763
+ author: str | None = None,
764
+ ) -> dict:
765
+ """Testable core: write the successor, then anchor it (explicit anchors, or inherit).
766
+
767
+ Mirrors ``_supersede_decision_impl`` exactly (falsification, not deletion): the
768
+ predecessor must already exist; ``supports`` defaults to the PREDECESSOR's ``supports``
769
+ when omitted (a superseding fact informs the same decisions unless told otherwise).
770
+ ``anchors`` given -> resolve fresh via ``_bind_fact_anchors`` (same no-graph-orphans
771
+ guarantee ``add_fact`` gives). ``anchors`` omitted -> copy the predecessor's bindings
772
+ verbatim (same entity_id/tier/weight/relation/status, including ``orphaned``) onto the
773
+ successor — nothing is "skipped" on this path since inheritance never resolves against
774
+ the graph.
775
+
776
+ ``session_id``/``author`` (I1, R1 improvement wave §1 — design D6 shape, same fields
777
+ ``_supersede_decision_impl`` takes): stamped onto the successor's ``Provenance``, plus
778
+ the same D7.3 fallback when the caller passes no ``session_id``. Unlike
779
+ ``_supersede_decision_impl``, there is no ``source`` override param here — ``source``
780
+ already names the FACT's own epistemics text (this function's positional ``source``
781
+ argument, e.g. "benchmark run"); provenance ``source`` stays hardcoded ``"human"``,
782
+ the same choice ``_add_fact_impl``/``add_fact`` already make for the identical reason.
783
+
784
+ Reachability gate (design D8), no-anchors path only: this path used to inherit the
785
+ predecessor's ``supports`` verbatim with no re-check, so a predecessor whose sole
786
+ supporting decision has since gone terminal produced a successor born flagged by
787
+ doctor's tightened ``dangling-record`` check. Gated only when the predecessor has NO
788
+ binding to inherit either — when it does, the binding-inheritance loop below carries a
789
+ real anchor forward regardless of ``supports``, and that already-reachable ordinary case
790
+ must not be rejected (``anchors`` requested is what "anchorless" means here, per D8's
791
+ residual note, but a predecessor's inherited BINDING is not a request — it is the same
792
+ reachability the predecessor already had).
793
+ """
794
+ predecessor = store.get_fact(old_fact_id)
795
+ if predecessor is None:
796
+ raise ValueError(f"unknown fact {old_fact_id!r}")
797
+ _validate_anchor_relations(anchors)
798
+ effective_supports = supports if supports is not None else predecessor.supports
799
+ if not anchors and not store.bindings_for_record(old_fact_id):
800
+ _require_fact_reachability(store, anchors, effective_supports)
801
+ # statement/source are both required (non-Optional) — redact directly, same reasoning
802
+ # as _add_decision_impl (keeps them typed `str`, not `_redact_fields`'s `str | None`).
803
+ statement, n1 = redact(statement)
804
+ source, n2 = redact(source)
805
+ redactions = n1 + n2
806
+ graph_version = reader.graph_version() if reader is not None else None
807
+ if session_id is None:
808
+ session_id = _session_id_fallback(store)
809
+ replacement = Fact(
810
+ statement=statement,
811
+ source=source,
812
+ supports=effective_supports,
813
+ status=DecisionStatus.ACCEPTED,
814
+ valid_from=datetime.now(UTC),
815
+ supersedes=old_fact_id,
816
+ provenance=Provenance(
817
+ source="human",
818
+ author=author,
819
+ session_id=session_id,
820
+ graph_version=graph_version,
821
+ # П0 (git-bindings design, Blocker 1): same best-effort HEAD stamp every
822
+ # other write path in the mirror now applies.
823
+ commit=_capture_commit(store),
824
+ ),
825
+ )
826
+ store.add_fact(replacement)
827
+
828
+ if anchors:
829
+ anchors_skipped, anchors_orphaned = _bind_fact_anchors(
830
+ replacement.id, anchors, reader, store
831
+ )
832
+ else:
833
+ # Inheritance resolves nothing — same reasoning as _supersede_decision_impl's.
834
+ anchors_skipped, anchors_orphaned = [], []
835
+ for b in store.bindings_for_record(old_fact_id):
836
+ store.add_binding(
837
+ AnchorBinding(
838
+ record_id=replacement.id,
839
+ entity_id=b.entity_id,
840
+ tier=b.tier,
841
+ weight=b.weight,
842
+ status=b.status,
843
+ relation=b.relation,
844
+ )
845
+ )
846
+
847
+ bindings = store.bindings_for_record(replacement.id)
848
+ return {
849
+ "id": replacement.id,
850
+ "statement": replacement.statement,
851
+ "status": replacement.status.value,
852
+ "redactions": redactions,
853
+ "entities": _entity_summaries(store, bindings),
854
+ "anchors_skipped": anchors_skipped,
855
+ "anchors_orphaned": anchors_orphaned,
856
+ "supersedes": old_fact_id,
857
+ }
858
+
859
+
860
+ @mcp.tool
861
+ def supersede_fact(
862
+ old_fact_id: str,
863
+ statement: str,
864
+ source: str,
865
+ supports: list[str] | None = None,
866
+ anchors: list[dict] | None = None,
867
+ session_id: str | None = None,
868
+ author: str | None = None,
869
+ ) -> dict:
870
+ """Falsify a fact: close the old one and append a replacement that supersedes it.
871
+
872
+ The predecessor is not deleted — it stays retrievable as "believed before, corrected
873
+ because…". Every text field is redacted first, exactly like ``add_fact``'s.
874
+ ``supports`` defaults to the predecessor's ``supports`` when omitted.
875
+
876
+ Anchoring: pass ``anchors`` (same shape as ``add_fact``'s) to resolve and bind the
877
+ successor to ONLY those refs (best-effort with a graph, orphaned-leaf fallback without
878
+ one — same as ``add_fact``). Omit ``anchors`` (the default) to INHERIT the
879
+ predecessor's bindings VERBATIM instead — same entity_id/tier/weight/relation/status,
880
+ including any ``orphaned`` ones carried as-is. Passing ``anchors`` replaces
881
+ inheritance; it never adds to it.
882
+
883
+ ``session_id``/``author`` (optional, I1 — R1 improvement wave §1) are stamped onto the
884
+ successor's provenance, same as ``add_decision``'s/``supersede_decision``'s; an
885
+ unpassed ``session_id`` falls back to the fresh Stop-channel marker when one exists
886
+ (design D7.3). Provenance ``source`` always stamps ``"human"`` here — same as
887
+ ``add_fact``'s.
888
+
889
+ Returns ``{"id", "statement", "status", "redactions", "entities", "anchors_skipped",
890
+ "supersedes"}`` — same shape as ``add_fact``'s plus ``supersedes`` (the predecessor's
891
+ id).
892
+ """
893
+ return _supersede_fact_impl(
894
+ _get_store(),
895
+ _load_reader(),
896
+ old_fact_id,
897
+ statement,
898
+ source,
899
+ supports=supports,
900
+ anchors=anchors,
901
+ session_id=session_id,
902
+ author=author,
903
+ )
904
+
905
+
906
+ def _retrieve_decisions_impl(store, include_superseded: bool = False) -> list[dict]:
907
+ decisions = list(store.iter_decisions())
908
+ if not include_superseded:
909
+ decisions = [
910
+ d
911
+ for d in decisions
912
+ if d.status not in (DecisionStatus.SUPERSEDED, DecisionStatus.REJECTED)
913
+ ]
914
+ # The proposal-surfacing policy applies HERE too (practitioner re-review round 2). This
915
+ # raw listing is deliberately unranked — but "unranked" is a ranking exemption, not a
916
+ # policy exemption: regulated mode and the surfacing window exist to keep unreviewed
917
+ # text away from an agent, and an MCP tool that hands it over anyway is a documented
918
+ # bypass of a security control. Accepted records are untouched.
919
+ decisions = [
920
+ d for d in decisions if d.status != DecisionStatus.PROPOSED or proposal_surfaces(d)
921
+ ]
922
+ # Mistakes first: gotchas and lessons before ADRs/constraints.
923
+ rank = {DecisionKind.GOTCHA: 0, DecisionKind.LESSON: 1}
924
+ decisions.sort(key=lambda d: rank.get(d.kind, 2))
925
+ return [d.model_dump(mode="json") for d in decisions]
926
+
927
+
928
+ @mcp.tool
929
+ def retrieve_decisions(include_superseded: bool = False) -> list[dict]:
930
+ """Return decisions from the store, mistakes/gotchas ranked first.
931
+
932
+ The default listing excludes superseded and rejected (dropped) records; pass
933
+ ``include_superseded=True`` to see that history too.
934
+ """
935
+ return _retrieve_decisions_impl(_get_store(), include_superseded=include_superseded)
936
+
937
+
938
+ def _list_facts_impl(store, include_superseded: bool = False) -> list[dict]:
939
+ """Testable core for list_facts (Gap 1, design/superpowers/specs/
940
+ 2026-07-10-ratification-ux-and-mcp-gaps-design.md) -- mirrors
941
+ ``_retrieve_decisions_impl``'s default filtering (excludes SUPERSEDED/REJECTED) and
942
+ full model-dump contract. Sort differs: facts carry no ``kind``, so there is no
943
+ mistakes-first ranking analogue -- sorted purely newest-first (``valid_from`` desc,
944
+ ``id`` desc as a deterministic tiebreak)."""
945
+ facts = list(store.iter_facts())
946
+ if not include_superseded:
947
+ facts = [
948
+ f for f in facts if f.status not in (DecisionStatus.SUPERSEDED, DecisionStatus.REJECTED)
949
+ ]
950
+ # Same policy application as `_retrieve_decisions_impl` — see its comment.
951
+ facts = [f for f in facts if f.status != DecisionStatus.PROPOSED or proposal_surfaces(f)]
952
+ facts.sort(key=lambda f: (f.valid_from, f.id), reverse=True)
953
+ return [f.model_dump(mode="json") for f in facts]
954
+
955
+
956
+ @mcp.tool
957
+ def list_facts(include_superseded: bool = False) -> list[dict]:
958
+ """Return facts from the store, newest first (the ``retrieve_decisions`` counterpart
959
+ for the facts layer -- Gap 1, previously only reachable via ``get_entity_history``,
960
+ which itself silently dropped facts until this same wave closed Gap 2).
961
+
962
+ The default listing excludes superseded and rejected (dropped) records; pass
963
+ ``include_superseded=True`` to see that history too. Facts have no ``kind`` (no
964
+ mistakes-first ranking, unlike ``retrieve_decisions``'s gotchas/lessons-first order)
965
+ -- sorted by ``valid_from`` descending, ``id`` descending as a deterministic tiebreak.
966
+ Full ``Fact`` model dumps.
967
+ """
968
+ return _list_facts_impl(_get_store(), include_superseded=include_superseded)
969
+
970
+
971
+ def _find_entity_impl(store, name: str, file_path: str | None = None) -> dict:
972
+ """Testable core: exact descriptor match first, then a name-only fallback scan.
973
+
974
+ Never guesses: a name reused across files with no ``file_path`` to disambiguate comes
975
+ back as ``candidates`` rather than an arbitrary pick.
976
+ """
977
+ entity = store.find_entity(name, file_path)
978
+ if entity is None:
979
+ candidates = store.find_entities_by_name(name)
980
+ if len(candidates) == 1:
981
+ entity = candidates[0]
982
+ elif len(candidates) > 1:
983
+ return {
984
+ "found": False,
985
+ "candidates": [
986
+ {
987
+ "entity_id": c.entity_id,
988
+ "canonical_name": c.canonical_name,
989
+ "file_path": c.descriptor.file_path if c.descriptor else None,
990
+ }
991
+ for c in candidates
992
+ ],
993
+ }
994
+ if entity is None:
995
+ return {"found": False}
996
+
997
+ bindings = store.bindings_for_entity(entity.entity_id)
998
+ return {
999
+ "found": True,
1000
+ "entity_id": entity.entity_id,
1001
+ "canonical_name": entity.canonical_name,
1002
+ "descriptor": entity.descriptor.model_dump() if entity.descriptor else None,
1003
+ "last_seen_node_id": entity.last_seen_node_id,
1004
+ "bindings": [
1005
+ {
1006
+ "record_id": b.record_id,
1007
+ "record_type": "fact" if store.get_fact(b.record_id) else "decision",
1008
+ "tier": b.tier,
1009
+ "status": b.status,
1010
+ }
1011
+ for b in bindings
1012
+ ],
1013
+ }
1014
+
1015
+
1016
+ @mcp.tool
1017
+ def find_entity(name: str, file_path: str | None = None) -> dict:
1018
+ """Look up an entity_id by name (+ optional file_path) — the missing link that lets an
1019
+ agent chain ``add_decision``/``propose_decisions`` output into ``get_entity_history``
1020
+ without reading the store directly.
1021
+
1022
+ Tries an exact descriptor match (canonicalized name + file_path) first; if that misses,
1023
+ falls back to a name-only scan across all entities. A single name-only match is
1024
+ returned as found; multiple matches are ambiguous and returned as ``candidates``
1025
+ (never guessed at) — pass ``file_path`` to disambiguate.
1026
+
1027
+ Returns ``{"found": True, "entity_id", "canonical_name", "descriptor", ...
1028
+ "last_seen_node_id", "bindings": [{"record_id", "record_type", "tier", "status"}, ...]}``
1029
+ (``record_type`` is ``"decision"`` or ``"fact"``) when resolved to exactly one entity;
1030
+ ``{"found": False}`` when nothing matches; or
1031
+ ``{"found": False, "candidates": [{"entity_id", "canonical_name", "file_path"}, ...]}``
1032
+ when the name alone is ambiguous.
1033
+ """
1034
+ return _find_entity_impl(_get_store(), name, file_path)
1035
+
1036
+
1037
+ def _get_entity_history_impl(store: Store, entity_id: str) -> list[dict]:
1038
+ """Testable core for get_entity_history (Gap 2, design/superpowers/specs/
1039
+ 2026-07-10-ratification-ux-and-mcp-gaps-design.md): every decision AND fact anchored
1040
+ to ``entity_id``, newest first.
1041
+
1042
+ Per binding: try ``get_decision(record_id)``, else ``get_fact(record_id)``, else skip
1043
+ (an unknown record kind stays skipped, same as before this wave). Previously this only
1044
+ ever tried ``get_decision`` -- a fact-only binding vanished from history with no trace.
1045
+ Every returned dict gains ``"record_type": "decision" | "fact"`` (additive -- existing
1046
+ consumers keyed on the pre-existing fields are unaffected); the merged list stays
1047
+ sorted ``valid_from`` desc, exactly as before.
1048
+ """
1049
+ bindings = store.bindings_for_entity(entity_id)
1050
+ records: list[tuple[str, Decision | Fact]] = []
1051
+ for b in bindings:
1052
+ decision = store.get_decision(b.record_id)
1053
+ if decision is not None:
1054
+ records.append(("decision", decision))
1055
+ continue
1056
+ fact = store.get_fact(b.record_id)
1057
+ if fact is not None:
1058
+ records.append(("fact", fact))
1059
+ records.sort(key=lambda pair: pair[1].valid_from, reverse=True)
1060
+ out = []
1061
+ for record_type, record in records:
1062
+ dump = record.model_dump(mode="json")
1063
+ dump["record_type"] = record_type
1064
+ out.append(dump)
1065
+ return out
1066
+
1067
+
1068
+ @mcp.tool
1069
+ def get_entity_history(entity_id: str) -> list[dict]:
1070
+ """Return every decision AND fact anchored to a given entity, newest first.
1071
+
1072
+ Per binding, tries a decision lookup then a fact lookup (an unknown record kind is
1073
+ skipped, as before). Every dict now carries ``"record_type": "decision" | "fact"`` so
1074
+ a caller can tell them apart without re-deriving it -- facts used to be silently
1075
+ dropped here (this tool only ever called ``get_decision``; see ``list_facts`` for the
1076
+ facts-only counterpart of ``retrieve_decisions``).
1077
+ """
1078
+ return _get_entity_history_impl(_get_store(), entity_id)
1079
+
1080
+
1081
+ def _seeds_from_args(files: list[str] | None, entities: list[dict] | None) -> list[Seed]:
1082
+ """Shared seed-building for get_task_context/query_structure/query_decisions (§5 FR8.2:
1083
+ the thin tools reuse this instead of re-deriving seeds from files/entities each time)."""
1084
+ seeds: list[Seed] = [Seed(file_path=f) for f in (files or [])]
1085
+ seeds += [Seed(name=e.get("name"), file_path=e.get("file_path")) for e in (entities or [])]
1086
+ return seeds
1087
+
1088
+
1089
+ def _get_task_context_impl(
1090
+ store,
1091
+ reader,
1092
+ files: list[str] | None,
1093
+ entities: list[dict] | None,
1094
+ structure_budget: int,
1095
+ memory_budget: int,
1096
+ ) -> str:
1097
+ """Testable core: build seeds, run retrieval, return the rendered slice."""
1098
+ seeds = _seeds_from_args(files, entities)
1099
+ ctx = _retrieve(seeds, store, reader, RetrievalBudget(structure_budget, memory_budget))
1100
+ _record(store, ctx.shown_ids, [s.file_path for s in seeds if s.file_path])
1101
+ return ctx.render()
1102
+
1103
+
1104
+ def _synced_reader() -> GraphifyReader | None:
1105
+ """Best-effort reader with a lazy sync attempt — shared by every retrieval-facing tool
1106
+ (get_task_context/query_structure/query_decisions/drill_down). Sync failure degrades
1107
+ to un-synced retrieval, never an error."""
1108
+ reader = _load_reader()
1109
+ with contextlib.suppress(Exception):
1110
+ maybe_sync(_get_store(), reader)
1111
+ return reader
1112
+
1113
+
1114
+ def _get_task_context_with_sync(
1115
+ files: list[str] | None = None,
1116
+ entities: list[dict] | None = None,
1117
+ structure_budget: int = 4000,
1118
+ memory_budget: int = 6000,
1119
+ ) -> str:
1120
+ """Tool-shell core: lazy sync (best-effort), then retrieval."""
1121
+ reader = _synced_reader()
1122
+ return _get_task_context_impl(
1123
+ _get_store(), reader, files, entities, structure_budget, memory_budget
1124
+ )
1125
+
1126
+
1127
+ def _query_structure_impl(
1128
+ store,
1129
+ reader,
1130
+ files: list[str] | None,
1131
+ entities: list[dict] | None,
1132
+ budget_chars: int,
1133
+ ) -> str:
1134
+ """Testable core for the query_structure thin tool (§5 FR8.2).
1135
+
1136
+ Records nothing at all: the never-surfaced denominator counts opportunities for a
1137
+ decision to surface, and this tool returns no decision memory, so it never offers one.
1138
+ Counting its seeds would inflate that denominator with non-opportunities — an area
1139
+ explored only structurally could then get flagged "never surfaced" when no decision
1140
+ could possibly have fired there (fix-wave review, spec correction over the original
1141
+ design's "record seeds to prove an area was visited").
1142
+ """
1143
+ return _query_structure(_seeds_from_args(files, entities), store, reader, budget_chars)
1144
+
1145
+
1146
+ def _query_decisions_impl(
1147
+ store,
1148
+ reader,
1149
+ files: list[str] | None,
1150
+ entities: list[dict] | None,
1151
+ budget_chars: int,
1152
+ ) -> str:
1153
+ """Testable core for the query_decisions thin tool (§5 FR8.2).
1154
+
1155
+ Reuses ``retrieval.get_task_context`` (aliased ``_retrieve``) rather than
1156
+ ``retrieval.query_decisions`` (a render-only wrapper that discards its ``TaskContext``)
1157
+ — same ``resolve_seeds`` -> ``_gather_structure`` -> ``rank_decisions`` pipeline,
1158
+ equivalent budget (``memory_chars=budget_chars``, default ``structure_chars`` since this
1159
+ tool takes none), just with the ``ctx`` kept around long enough to read
1160
+ ``ctx.shown_ids`` for telemetry before rendering with ``include_structure=False``.
1161
+ """
1162
+ seeds = _seeds_from_args(files, entities)
1163
+ ctx = _retrieve(seeds, store, reader, RetrievalBudget(memory_chars=budget_chars))
1164
+ _record(store, ctx.shown_ids, [s.file_path for s in seeds if s.file_path])
1165
+ return ctx.render(include_structure=False)
1166
+
1167
+
1168
+ @mcp.tool
1169
+ def get_task_context(
1170
+ files: list[str] | None = None,
1171
+ entities: list[dict] | None = None,
1172
+ structure_budget: int = 4000,
1173
+ memory_budget: int = 6000,
1174
+ ) -> str:
1175
+ """Task-aware context for the files/entities you're working on, mistakes ranked first.
1176
+
1177
+ ``files`` are repo-relative paths; ``entities`` are ``{"name": ..., "file_path": ...}``
1178
+ refs. Returns a compact slice: known mistakes/gotchas, then decisions, then a structural
1179
+ map, then related decisions. Best-effort — degrades if the graph or store is absent.
1180
+ """
1181
+ return _get_task_context_with_sync(files, entities, structure_budget, memory_budget)
1182
+
1183
+
1184
+ @mcp.tool
1185
+ def query_structure(
1186
+ files: list[str] | None = None,
1187
+ entities: list[dict] | None = None,
1188
+ budget_chars: int = 4000,
1189
+ ) -> str:
1190
+ """The structural-map half of ``get_task_context`` alone (§5 FR8.2 thin tool) — a cheap
1191
+ follow-up once you already have decision memory and just need the code map.
1192
+
1193
+ Same ``files``/``entities`` shape as ``get_task_context``. Never crashes: with no
1194
+ Graphify graph present, returns an explanatory note instead of a map.
1195
+ """
1196
+ return _query_structure_impl(_get_store(), _synced_reader(), files, entities, budget_chars)
1197
+
1198
+
1199
+ @mcp.tool
1200
+ def query_decisions(
1201
+ files: list[str] | None = None,
1202
+ entities: list[dict] | None = None,
1203
+ budget_chars: int = 6000,
1204
+ ) -> str:
1205
+ """The decision-memory half of ``get_task_context`` alone (§5 FR8.2 thin tool):
1206
+ mistakes, decisions, related — no structural map.
1207
+
1208
+ Same ``files``/``entities`` shape as ``get_task_context``. Best-effort like every other
1209
+ tool here: degrades gracefully with no graph present (global-scope decisions still
1210
+ surface). This tool takes no ``structure_budget``, but internally the "related"
1211
+ (peripheral) bucket is still gathered by walking the structural subgraph with
1212
+ ``RetrievalBudget``'s DEFAULT ``structure_chars`` (the map itself is discarded — only
1213
+ the peripheral entities it surfaces feed decision ranking).
1214
+ """
1215
+ return _query_decisions_impl(_get_store(), _synced_reader(), files, entities, budget_chars)
1216
+
1217
+
1218
+ def _auto_accept() -> bool:
1219
+ """True iff ``SIDEGRAPH_AUTO_ACCEPT=on`` (point-of-use env read — never cached at
1220
+ import, and never read inside ``capture.py``, which stays pure and takes the resolved
1221
+ bool as a keyword instead). Any value other than the literal ``"on"`` (including unset)
1222
+ is off. When on, agent-proposed decisions and facts (``propose_decisions``) land
1223
+ ``status=accepted`` directly instead of ``proposed``, bypassing the human ratification
1224
+ queue — provenance still stamps ``source="agent"``, so history never lies about
1225
+ authorship, only about whether a human reviewed it. Domains are always exempt
1226
+ (``propose_domains``/``_add_domain_impl`` never consult this). Opt-in, off by default:
1227
+ it removes the store's only noise filter, so it's recommended for solo use, not team
1228
+ stores (see design/superpowers/specs/2026-07-10-ratification-ux-and-mcp-gaps-design.md).
1229
+ """
1230
+ return os.environ.get("SIDEGRAPH_AUTO_ACCEPT") == "on"
1231
+
1232
+
1233
+ def _ratify_policy() -> RatifyPolicy:
1234
+ """Point-of-use resolver for ``SIDEGRAPH_RATIFY_POLICY`` (design D1) — mirrors
1235
+ ``_auto_accept``'s shape: a fresh env read at the point of use (never cached at
1236
+ import), never performed inside ``capture.py`` (which stays pure and takes the
1237
+ resolved ``RatifyPolicy`` as a keyword instead). Unknown/empty/unset values fail safe
1238
+ to ``RatifyPolicy.MANUAL`` via the pure ``capture.parse_ratify_policy`` this function
1239
+ wraps with the actual env read.
1240
+
1241
+ Called exactly ONCE per MCP request — inside ``propose_decisions`` and
1242
+ ``propose_domains`` — and the returned object is threaded through unchanged to every
1243
+ core call the request makes (``propose_decisions`` passes the SAME object to both
1244
+ ``capture.propose`` and ``capture.propose_facts`` via ``_propose_decisions_impl``), so
1245
+ a single batch samples the policy once, never once per core call.
1246
+ # see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D1
1247
+ """
1248
+ return parse_ratify_policy(os.environ.get("SIDEGRAPH_RATIFY_POLICY"))
1249
+
1250
+
1251
+ def _telemetry_enabled() -> bool:
1252
+ """Opt-out, one definition shared with the PreToolUse hook (see config)."""
1253
+ from .config import telemetry_enabled
1254
+
1255
+ return telemetry_enabled()
1256
+
1257
+
1258
+ # D2: this process has no idea what the host calls the current session; SessionStart wrote
1259
+ # it into `meta` before any tool ran.
1260
+ _SESSION_KEY_TTL = timedelta(hours=12)
1261
+
1262
+
1263
+ def _session_key(store: Store) -> str | None:
1264
+ """The current host session id, or None when there is no trustworthy one.
1265
+
1266
+ Absent, unparsable, or older than the TTL all mean the same thing: record nothing.
1267
+ `meta` never expires on its own, so without the TTL check a key left behind by the last
1268
+ session would silently attribute every later CLI or pytest retrieval to it — including
1269
+ handing seed events to a dead session that had only touches.
1270
+ """
1271
+ raw = store.get_meta(TELEMETRY_SESSION_KEY)
1272
+ if not raw:
1273
+ return None
1274
+ session_id, separator, stamp = raw.partition("|")
1275
+ if not session_id or not separator:
1276
+ return None
1277
+ try:
1278
+ written = datetime.fromisoformat(stamp)
1279
+ except ValueError:
1280
+ return None
1281
+ if written.tzinfo is None:
1282
+ return None
1283
+ if datetime.now(UTC) - written > _SESSION_KEY_TTL:
1284
+ return None
1285
+ return session_id
1286
+
1287
+
1288
+ def _anchor_paths(store: Store, record_id: str) -> list[str]:
1289
+ """File paths a record is anchored to, resolved through the INDEX.
1290
+
1291
+ Doctor resolves the same relation by walking canonical JSON (`doctor.py:482-485`), which
1292
+ is right for a one-shot check and wrong here, where this runs on every retrieval.
1293
+ **All bindings count regardless of status**: doctor's canonical read has no status to
1294
+ filter on, so dropping degraded/orphaned ones here would make the two resolutions
1295
+ disagree about what a record is anchored to.
1296
+ """
1297
+ paths: list[str] = []
1298
+ for binding in store.bindings_for_record(record_id):
1299
+ entity = store.get_entity(binding.entity_id)
1300
+ file_path = entity.descriptor.file_path if entity and entity.descriptor else None
1301
+ if file_path:
1302
+ paths.append(file_path)
1303
+ return paths
1304
+
1305
+
1306
+ def _normalized_seeds(store: Store, seeds: list[str]) -> list[str]:
1307
+ """Seed keys must join anchors, so they get the same realpath+relpath treatment touches
1308
+ get — seeds arrive verbatim from agent arguments (`_seeds_from_args`), and an agent that
1309
+ passes absolute paths would otherwise write absolute keys that match no anchor.
1310
+
1311
+ The consequence is not a missed join but a WRONG NUMBER: the redirect metric is
1312
+ `(shown anchors - seeds) & touched`, so a seed set that fails to match inflates the
1313
+ deliverable in the flattering direction. For the same reason this normalizes rather than
1314
+ drops — a dropped seed shrinks the set and over-counts too. Only an out-of-root seed is
1315
+ discarded, and only because no anchor can equal it in any form.
1316
+
1317
+ Shared by BOTH of `_record`'s writes (fix-wave finding): the aggregate `retrieval_seeds`
1318
+ counter and the `retrieval_events` journal used to see different shapes of the same call
1319
+ — an absolute-path retrieval landed relative in the journal but absolute in the
1320
+ aggregate, which permanently zeroed doctor's `never-surfaced` "people asked there"
1321
+ signal for that file (the counter is cumulative and never resets). A `domain:<slug>`
1322
+ seed (drill_down's, with no file to normalize against) passes through unchanged here —
1323
+ it is filtered out only where `_record` builds the journal-bound list, never here.
1324
+ """
1325
+ root = os.path.realpath(Path(store.path).parent)
1326
+ out: list[str] = []
1327
+ for seed in seeds:
1328
+ try:
1329
+ target = os.path.realpath(seed if os.path.isabs(seed) else os.path.join(root, seed))
1330
+ rel = os.path.relpath(target, root)
1331
+ except (OSError, ValueError):
1332
+ continue
1333
+ if rel == os.curdir or rel.startswith(os.pardir):
1334
+ continue
1335
+ out.append(rel)
1336
+ return out
1337
+
1338
+
1339
+ def _record(store: Store, record_ids: list[str], seeds: list[str]) -> None:
1340
+ """Best-effort telemetry. Swallows everything: a retrieval that failed because a
1341
+ counter could not be written would be strictly worse than no counters (D9).
1342
+
1343
+ Seeds are normalized ONCE, up front, so the aggregate `retrieval_seeds` counter and the
1344
+ `retrieval_events` journal agree on the same key shape for the SAME call (fix-wave
1345
+ finding: they used to disagree — raw seeds into the counter, normalized into the
1346
+ journal — which left an absolute-path retrieval's aggregate entry permanently unable to
1347
+ join `descriptor.file_path` and silently zeroed doctor's `never-surfaced` signal for that
1348
+ file). The normalization itself is wrapped in its own suppress: a failure there must
1349
+ degrade to the pre-fix (raw) seeds rather than losing telemetry entirely, per D9.
1350
+
1351
+ Two independent writes after that. The aggregate counters answer "which memory is dead"
1352
+ and need no session; the journal answers "did memory arrive when it was for" and is
1353
+ useless without one, so a missing session key skips the journal alone and never the
1354
+ counters. The journal write additionally drops `domain:<slug>` seeds (drill_down's,
1355
+ spec §4: "a seed with no file writes no event") — the aggregate keeps them, since
1356
+ `retrieval_seeds` has always counted that key (see `retrieval_seed_queries`'s pinned
1357
+ `{"domain:payments": 1}`) and only the journal's storage contract excludes pathless keys.
1358
+ """
1359
+ if not _telemetry_enabled():
1360
+ return
1361
+ normalized_seeds = seeds
1362
+ with contextlib.suppress(Exception):
1363
+ normalized_seeds = _normalized_seeds(store, seeds)
1364
+ with contextlib.suppress(Exception):
1365
+ store.record_retrieval(record_ids, normalized_seeds)
1366
+ with contextlib.suppress(Exception):
1367
+ session_id = _session_key(store)
1368
+ if session_id is None:
1369
+ return
1370
+ shows = [
1371
+ (record_id, path)
1372
+ for record_id in dict.fromkeys(record_ids)
1373
+ for path in _anchor_paths(store, record_id)
1374
+ ]
1375
+ journal_seeds = [s for s in normalized_seeds if not s.startswith("domain:")]
1376
+ store.record_retrieval_events(session_id, journal_seeds, shows)
1377
+
1378
+
1379
+ def _propose_decisions_impl(
1380
+ store,
1381
+ reader,
1382
+ drafts: list[dict],
1383
+ session_id: str | None = None,
1384
+ author: str | None = None,
1385
+ facts: list[dict] | None = None,
1386
+ auto_accept: bool = False,
1387
+ ratify_policy: RatifyPolicy = RatifyPolicy.MANUAL,
1388
+ ) -> list[dict]:
1389
+ """Testable core for propose_decisions (see capture.propose/propose_facts).
1390
+
1391
+ ``facts`` are STANDALONE fact drafts (as opposed to a ``DraftDecision.facts`` entry,
1392
+ which rides its own decision draft and is handled inside ``capture.propose`` already) —
1393
+ run through ``capture.propose_facts`` after every decision draft has been processed, and
1394
+ their result dicts appended after the decision results, never interleaved.
1395
+
1396
+ ``auto_accept`` (default ``False``) is the resolved ``SIDEGRAPH_AUTO_ACCEPT`` bool (see
1397
+ ``_auto_accept``/design/superpowers/specs/2026-07-10-ratification-ux-and-mcp-gaps-design.md)
1398
+ — passed through to both ``propose`` and ``propose_facts`` unchanged, so decision drafts,
1399
+ their attached facts, and standalone facts all land ``accepted`` together when it's on.
1400
+
1401
+ ``ratify_policy`` (default ``RatifyPolicy.MANUAL``) is the resolved
1402
+ ``SIDEGRAPH_RATIFY_POLICY`` value (design D1, ``server._ratify_policy()``) — the SAME
1403
+ object is passed through to both ``propose`` and ``propose_facts`` unchanged, the whole
1404
+ point being that one MCP ``propose_decisions`` request samples the policy exactly once,
1405
+ not once per core call.
1406
+ # see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D1
1407
+ """
1408
+ results = [
1409
+ r.model_dump(mode="json")
1410
+ for r in propose(
1411
+ drafts,
1412
+ store,
1413
+ reader,
1414
+ session_id=session_id,
1415
+ author=author,
1416
+ auto_accept=auto_accept,
1417
+ ratify_policy=ratify_policy,
1418
+ )
1419
+ ]
1420
+ if facts:
1421
+ results.extend(
1422
+ r.model_dump(mode="json")
1423
+ for r in propose_facts(
1424
+ facts,
1425
+ store,
1426
+ reader,
1427
+ session_id=session_id,
1428
+ author=author,
1429
+ auto_accept=auto_accept,
1430
+ ratify_policy=ratify_policy,
1431
+ )
1432
+ )
1433
+ return results
1434
+
1435
+
1436
+ def _format_domain_proposal_line(d: Domain) -> str:
1437
+ """One-line domain-draft render for ``_list_proposed_impl`` (id, slug, title, summary,
1438
+ membership rule) — deliberately more compact than ``capture.format_domain_proposal``'s
1439
+ multi-line CLI render, since ``list_proposed`` packs everything pending into one
1440
+ MCP-tool response. Always renders the membership rule — ``path_prefixes``/
1441
+ ``communities`` (via the shared ``format_path_prefixes``/``format_communities_sample``
1442
+ helpers, Gate-5 finding) and ``seed_anchors`` (via ``format_seed_anchors_sample``,
1443
+ Gate-6 finding) — so the human gate has the same rule visibility here as on the CLI.
1444
+ The ``anchors:`` segment is the one exception: omitted entirely when ``seed_anchors``
1445
+ is empty, rather than printing a third empty segment."""
1446
+ summary = d.summary.strip().splitlines()[0] if d.summary.strip() else ""
1447
+ paths = format_path_prefixes(d.path_prefixes)
1448
+ communities = format_communities_sample(d.communities)
1449
+ line = (
1450
+ f"{d.domain_id} [domain] {d.slug} — {d.title}: {summary} "
1451
+ f"(paths: {paths}; communities: {communities}"
1452
+ )
1453
+ anchors = format_seed_anchors_sample(d.seed_anchors)
1454
+ if anchors:
1455
+ line += f"; anchors: {anchors}"
1456
+ return line + ")"
1457
+
1458
+
1459
+ def _nested_fact_ids(store: Store, proposals: list[Decision]) -> set[str]:
1460
+ """Ids of still-``PROPOSED`` facts that support one of ``proposals`` -- these ride
1461
+ their decision's ratify verdict (cascade; see ``Store.ratify``/``Store.drop``) and are
1462
+ rendered nested under that decision in the queue, never listed again in the standalone
1463
+ "Facts:" section. Shared by ``_list_proposed_impl``, ``ratify_main``'s bare-run render,
1464
+ and ``--all``'s id collection so none of the three double-count a cascaded fact.
1465
+ """
1466
+ return {
1467
+ f.id
1468
+ for d in proposals
1469
+ for f in store.facts_for_decision(d.id)
1470
+ if f.status == DecisionStatus.PROPOSED
1471
+ }
1472
+
1473
+
1474
+ _NOT_SURFACING = "[not surfacing]"
1475
+
1476
+
1477
+ def _format_proposed_decision_block(store: Store, d: Decision) -> str:
1478
+ """``format_proposal(d)`` plus one indented `` evidence: ...`` line per still-
1479
+ ``PROPOSED`` fact supporting it (``store.facts_for_decision``) -- a preview of the
1480
+ cascade: ratifying this decision also ratifies these facts. Shared by
1481
+ ``_list_proposed_impl`` (MCP) and ``ratify_main``'s bare-run print so the two stay in
1482
+ lockstep."""
1483
+ # Round-2 practitioner review: mark what has already stopped being delivered. The
1484
+ # surfacing window is only humane if the queue says which items it has stopped
1485
+ # serving — otherwise a reviewer cannot tell an urgent backlog from an inert one, and
1486
+ # the window reads as a silent drop. The record stays listed and ratifiable either way.
1487
+ head = format_proposal(d)
1488
+ if not proposal_surfaces(d):
1489
+ # On the TITLE line, where the eye lands — not at the end of a multi-line block.
1490
+ first, _, rest = head.partition("\n")
1491
+ head = f"{first} {_NOT_SURFACING}" + (f"\n{rest}" if rest else "")
1492
+ lines = [head]
1493
+ for f in store.facts_for_decision(d.id):
1494
+ if f.status == DecisionStatus.PROPOSED:
1495
+ lines.append(f" evidence: {f.statement} [{f.source}] ({f.id})")
1496
+ return "\n".join(lines)
1497
+
1498
+
1499
+ def _list_proposed_impl(store) -> str:
1500
+ """Pending decisions, facts, AND domains (§2/§4 review PINNED I2; facts layer
1501
+ 2026-07-10), sectioned like ``sidegraph-ratify``'s bare listing (``cli.ratify_main``):
1502
+ "Decisions:" (each block carries its still-proposed supporting facts nested as
1503
+ `` evidence: ...`` lines) then "Facts:" (standalone proposed facts -- ones NOT nested
1504
+ under any decision above) then "Domains:", each printed only when non-empty."""
1505
+ proposals = list(store.iter_proposed())
1506
+ domains = list(store.iter_domains(status=DomainStatus.PROPOSED))
1507
+ nested = _nested_fact_ids(store, proposals)
1508
+ standalone_facts = [f for f in store.iter_proposed_facts() if f.id not in nested]
1509
+ if not proposals and not domains and not standalone_facts:
1510
+ return "No proposed decisions, facts, or domains pending ratification."
1511
+ sections: list[str] = []
1512
+ if proposals:
1513
+ sections.append(
1514
+ "Decisions:\n"
1515
+ + "\n\n".join(_format_proposed_decision_block(store, d) for d in proposals)
1516
+ )
1517
+ if standalone_facts:
1518
+ sections.append("Facts:\n" + "\n\n".join(format_fact_proposal(f) for f in standalone_facts))
1519
+ if domains:
1520
+ sections.append("Domains:\n" + "\n".join(_format_domain_proposal_line(d) for d in domains))
1521
+ return "\n\n".join(sections)
1522
+
1523
+
1524
+ @mcp.tool
1525
+ def propose_decisions(
1526
+ drafts: list[dict],
1527
+ session_id: str | None = None,
1528
+ author: str | None = None,
1529
+ facts: list[dict] | None = None,
1530
+ ) -> list[dict]:
1531
+ """Propose distilled decisions from this session (What/Why/Where/Learned drafts).
1532
+
1533
+ Each draft: {"title", "kind": adr|lesson|constraint|gotcha, "context", "choice",
1534
+ "rejected"?, "consequences"?, "anchors": [{"name", "file_path", "relation"?}],
1535
+ "initiative"?, "supersedes"?, "tags"? ([str], free text; slugified and redacted),
1536
+ "layer"? ("business"|"technical"), "facts"? ([DraftFact], attached — see below)}.
1537
+ Each anchor's ``relation`` (optional): creates|modifies|affects|deprecates|considered,
1538
+ defaults to "affects" — same five literals ``add_decision`` enumerates.
1539
+ The pipeline redacts secrets (including tag text), validates, dedups, writes as
1540
+ status=proposed (ratified later by a human — or at write time by an opt-in
1541
+ SIDEGRAPH_RATIFY_POLICY when the draft is eligible), and anchors best-effort —
1542
+ tags/layer/per-anchor relation carry through unchanged to ratification.
1543
+
1544
+ Each result carries ``"anchors_skipped": [{"name", "reason": "ambiguous", "candidates"}]``
1545
+ — anchors whose name matched more than one graph node, so no precise Tier-2 leaf was
1546
+ created for them (capped at 5); empty when every anchor resolved cleanly.
1547
+
1548
+ Each result also carries ``neighbors`` — up to 3 live records anchored to the same code
1549
+ (deduplicated; on a ``deduped`` result, the existing record itself). If your new record
1550
+ CHANGES, NARROWS or INVALIDATES one of them, do not leave both alive: call
1551
+ ``supersede_decision(old_decision_id=...)`` with the successor content.
1552
+
1553
+ ``facts`` (optional, top-level) proposes STANDALONE facts — non-derivable knowledge
1554
+ that doesn't attach to any decision drafted in this same call. Each is a DraftFact:
1555
+ {"statement", "source", "anchors"? ([{"name", "file_path", "relation"?}]), "supports"?
1556
+ ([decision id, ...])}. A standalone fact needs at least one anchor or one ``supports``
1557
+ id — otherwise it would be unreachable and is rejected with a reason. Compare: a
1558
+ draft's OWN ``"facts"`` list (inside a decision draft, not this top-level param) is
1559
+ ATTACHED — it always supports that decision and, absent its own anchors, inherits the
1560
+ decision's anchors; that path already runs inside each decision draft, unchanged by
1561
+ this parameter.
1562
+
1563
+ Standalone-fact results (``ProposeFactResult``-shaped: "status", "fact_id", "reason",
1564
+ "redactions", "anchors_skipped", "anchors_orphaned", "ratified_by", "auto_ratify_error")
1565
+ are appended to the returned list AFTER every decision draft's result, in ``facts``
1566
+ order — never interleaved with the decision results.
1567
+
1568
+ Each result also carries ``ratified_by`` (the ``auto:<policy>`` stamp when an
1569
+ auto-ratification policy accepted the record at write time, else null — including
1570
+ nested attached facts accepted through the cascade) and ``auto_ratify_error`` (null
1571
+ unless an attempt failed); ``status`` keeps its write-action meaning.
1572
+
1573
+ Auto-accept: when the ``SIDEGRAPH_AUTO_ACCEPT`` environment variable is set to ``"on"``
1574
+ (off by default), every decision draft, its attached facts, and every standalone fact
1575
+ land ``status=accepted`` directly instead of ``proposed`` — the pending-ratification
1576
+ queue is bypassed for this call. Provenance still stamps ``source="agent"`` regardless,
1577
+ so history never lies about authorship. Domain drafts (``propose_domains``) are NEVER
1578
+ affected by this flag. When both it and SIDEGRAPH_RATIFY_POLICY are set, this flag
1579
+ wins. See design/superpowers/specs/
1580
+ 2026-07-10-ratification-ux-and-mcp-gaps-design.md for the trade-off (auto-accept removes
1581
+ the store's only noise filter; recommended for solo use, not team stores).
1582
+ """
1583
+ return _propose_decisions_impl(
1584
+ _get_store(),
1585
+ _load_reader(),
1586
+ drafts,
1587
+ session_id=session_id,
1588
+ author=author,
1589
+ facts=facts,
1590
+ auto_accept=_auto_accept(),
1591
+ ratify_policy=_ratify_policy(),
1592
+ )
1593
+
1594
+
1595
+ @mcp.tool
1596
+ def list_proposed() -> str:
1597
+ """List decisions, facts, AND domains awaiting ratification, human-readably.
1598
+
1599
+ Sectioned like ``sidegraph-ratify``'s bare listing: a "Decisions:" section (each
1600
+ decision's still-proposed supporting facts nested under it as `` evidence: ...``
1601
+ lines), a "Facts:" section for standalone proposed facts, then a "Domains:" section --
1602
+ each printed only when non-empty (see ``_list_proposed_impl``).
1603
+ """
1604
+ return _list_proposed_impl(_get_store())
1605
+
1606
+
1607
+ def _ratify_one(store: Store, id_: str, action: str) -> tuple[str, list[Fact]]:
1608
+ """Route one id to a decision, a fact, or a domain by lookup (decision first, then
1609
+ fact, then domain) and apply ``action`` ("accept" | "drop"). Unknown ids get a generic
1610
+ error entry — never guess which kind an id belongs to. The known-decision branch
1611
+ mirrors ``_ratify_decisions_impl``'s per-id try/except exactly, so existing error text
1612
+ for a decision id is unchanged; a fact id's ``ratify_fact``/``drop_fact`` ValueError
1613
+ (not proposed) surfaces the same way. Only a genuinely unknown id's wording differs.
1614
+
1615
+ Returns ``(result, cascaded)`` — ``cascaded`` is the list of :class:`Fact` records that
1616
+ rode a DECISION's verdict in this call (``Store.ratify``/``Store.drop``'s own cascade;
1617
+ facts layer 2026-07-10), always empty for a fact or domain id since neither has
1618
+ anything of its own to cascade. The caller (``_ratify_impl``) turns this into per-fact
1619
+ result-dict entries.
1620
+ """
1621
+ if store.get_decision(id_) is not None:
1622
+ try:
1623
+ if action == "accept":
1624
+ _decision, cascaded = store.ratify(id_)
1625
+ return "accepted", cascaded
1626
+ _decision, cascaded = store.drop(id_)
1627
+ return "dropped", cascaded
1628
+ except ValueError as e:
1629
+ return f"error: {e}", []
1630
+ if store.get_fact(id_) is not None:
1631
+ try:
1632
+ if action == "accept":
1633
+ store.ratify_fact(id_)
1634
+ else:
1635
+ store.drop_fact(id_)
1636
+ return ("accepted" if action == "accept" else "dropped"), []
1637
+ except ValueError as e:
1638
+ return f"error: {e}", []
1639
+ if store.get_domain(id_) is not None:
1640
+ result = (
1641
+ store.ratify_domains(accept=[id_])
1642
+ if action == "accept"
1643
+ else store.ratify_domains(drop=[id_])
1644
+ )
1645
+ return result[id_], []
1646
+ return f"error: unknown id {id_!r} (not a pending decision, fact, or domain)", []
1647
+
1648
+
1649
+ def _ratified_domain(store: Store, id_: str, result: str) -> bool:
1650
+ """True iff ``id_`` was routed to (and actually landed on) a domain, not a decision or
1651
+ an unknown id — used to gate the TOC cache refresh below to real domain changes only."""
1652
+ if result.startswith("error"):
1653
+ return False
1654
+ return store.get_decision(id_) is None and store.get_domain(id_) is not None
1655
+
1656
+
1657
+ def _ratify_impl(
1658
+ store: Store,
1659
+ accept: list[str] | None = None,
1660
+ drop: list[str] | None = None,
1661
+ reader: GraphifyReader | None = None,
1662
+ ) -> dict[str, str]:
1663
+ """Testable core for the unified ``ratify`` tool: one gate covering decisions, facts,
1664
+ AND domains (§4, "one gate, no exceptions"; facts layer 2026-07-10). Accept-before-drop,
1665
+ same id in both -> drop is ignored (mirrors ``_ratify_decisions_impl``'s convention).
1666
+
1667
+ Cascade reporting: when an accepted/dropped id routes to a decision, every fact that
1668
+ rode its verdict (``_ratify_one``'s ``cascaded`` return) gets its OWN entry in the
1669
+ result dict too — ``f"accepted (evidence of {decision_id})"`` /
1670
+ ``f"dropped (evidence of {decision_id})"`` — so a caller sees every record this call
1671
+ actually touched, not just the ids it was explicitly given.
1672
+
1673
+ Accept order-independence (Task 7 fix pass, Important-1): the accept loop below runs
1674
+ in TWO passes — every decision id in ``accept`` first, regardless of its position in
1675
+ the caller's list, then everything else. A fact nested under one of these decisions
1676
+ must always be swept by ITS cascade, never independently re-ratified first just
1677
+ because it happened to be listed earlier — without this, ``accept=[d.id, f.id]`` and
1678
+ ``accept=[f.id, d.id]`` disagreed: the first order re-processed ``f.id`` after the
1679
+ cascade had already flipped it, raising a spurious ``"fact ... is not proposed"``
1680
+ error; the second silently produced a plain ``"accepted"`` instead of the
1681
+ cascade-attributed string, for the exact same final state. Both orders now produce
1682
+ identical output. A second-pass id already present in ``out`` (because a decision
1683
+ processed in pass one cascaded it) is skipped outright — same guard the drop loop
1684
+ below already relies on for its own cross-list (accept vs drop) dedup.
1685
+
1686
+ Fix: lazy sync alone keeps ``last_synced_graph_version`` current without ever
1687
+ recomputing the TOC cache, so "bootstrap -> ratify -> SessionStart TOC comes alive"
1688
+ did nothing until the next real graph rebuild. Rebuild the cache here, immediately,
1689
+ whenever >= 1 domain id was actually accepted or dropped in this call — a
1690
+ decisions-only ratify leaves the cache untouched (it wouldn't change the TOC anyway).
1691
+
1692
+ ``reader`` (the ``ratify``/``ratify_decisions`` tools' normal call, via
1693
+ ``_load_reader()``): when a domain is actually ACCEPTED in this call and a reader is
1694
+ present, its ``communities`` are resolved immediately from ``seed_anchors``/
1695
+ ``path_prefixes`` (``sync.refresh_domain_communities_now`` — §2a amendment) so
1696
+ ``drill_down`` shows membership the instant the human accepts a set, instead of
1697
+ waiting for the next graph-rebuild-gated ``sync`` pass. Best-effort: a resolution
1698
+ failure here must never fail the ratify call itself (mirrors every other best-effort
1699
+ engine touch in this module).
1700
+ """
1701
+ out: dict[str, str] = {}
1702
+ domain_changed = False
1703
+ accept_ids = accept or []
1704
+ # Pass 1: every decision id first (see docstring's "Accept order-independence"). No
1705
+ # domain-refresh check here -- a decision id can never satisfy `_ratified_domain`
1706
+ # (it requires `store.get_decision(id_) is None`, and this pass only ever routes ids
1707
+ # that ARE decisions), so that check lives solely in pass 2 below.
1708
+ for id_ in accept_ids:
1709
+ if id_ in out or store.get_decision(id_) is None:
1710
+ continue
1711
+ result, cascaded = _ratify_one(store, id_, "accept")
1712
+ out[id_] = result
1713
+ for f in cascaded:
1714
+ out[f.id] = f"accepted (evidence of {id_})"
1715
+ # Pass 2: everything else (facts, domains, unknown ids) -- an id already reported by a
1716
+ # pass-1 cascade is skipped, never re-processed against a record that no longer exists.
1717
+ for id_ in accept_ids:
1718
+ if id_ in out:
1719
+ continue
1720
+ result, cascaded = _ratify_one(store, id_, "accept")
1721
+ out[id_] = result
1722
+ for f in cascaded:
1723
+ out[f.id] = f"accepted (evidence of {id_})"
1724
+ if _ratified_domain(store, id_, out[id_]):
1725
+ domain_changed = True
1726
+ domain = store.get_domain(id_)
1727
+ if domain is not None:
1728
+ # sync.activate_accepted_domain (design D2 shared helper): resolves
1729
+ # membership now, or schedules the VOLATILE_STALE_KEY heal itself when
1730
+ # there is no reader or the refresh raises -- never fails this ratify
1731
+ # either way. Rendering the "path rule too broad" sentence stays HERE
1732
+ # (a literal trigger phrase for the heal-anchors skill), not in the helper.
1733
+ activation = activate_accepted_domain(domain, store, reader)
1734
+ if activation.overbroad is not None:
1735
+ prefixes = ", ".join(repr(p) for p in domain.path_prefixes)
1736
+ out[id_] += (
1737
+ f" (path rule too broad: {prefixes} match "
1738
+ f"{activation.overbroad['matched']}/{activation.overbroad['total']} "
1739
+ "communities — not applied; seed_anchors, if any, still applied)"
1740
+ )
1741
+ else:
1742
+ # The domain vanished between _ratified_domain's check and here (can only
1743
+ # happen under concurrent mutation) -- same unresolved-membership fallback
1744
+ # as a failed/absent-reader activation.
1745
+ store.set_meta(VOLATILE_STALE_KEY, "1")
1746
+ for id_ in drop or []:
1747
+ if id_ in out:
1748
+ out[id_] = f"{out[id_]} (drop ignored)"
1749
+ continue
1750
+ result, cascaded = _ratify_one(store, id_, "drop")
1751
+ out[id_] = result
1752
+ for f in cascaded:
1753
+ out[f.id] = f"dropped (evidence of {id_})"
1754
+ domain_changed = domain_changed or _ratified_domain(store, id_, out[id_])
1755
+ if domain_changed:
1756
+ store.set_meta(TOC_CACHE_KEY, json.dumps(build_toc(store)))
1757
+ return out
1758
+
1759
+
1760
+ def _ratify_decisions_impl(
1761
+ store, accept: list[str] | None = None, drop: list[str] | None = None
1762
+ ) -> dict[str, str]:
1763
+ out: dict[str, str] = {}
1764
+ for did in accept or []:
1765
+ try:
1766
+ _decision, _cascaded = store.ratify(did)
1767
+ out[did] = "accepted"
1768
+ except ValueError as e:
1769
+ out[did] = f"error: {e}"
1770
+ for did in drop or []:
1771
+ if did in out:
1772
+ out[did] = f"{out[did]} (drop ignored)"
1773
+ continue
1774
+ try:
1775
+ _decision, _cascaded = store.drop(did)
1776
+ out[did] = "dropped"
1777
+ except ValueError as e:
1778
+ out[did] = f"error: {e}"
1779
+ return out
1780
+
1781
+
1782
+ @mcp.tool
1783
+ def ratify(accept: list[str] | None = None, drop: list[str] | None = None) -> dict[str, str]:
1784
+ """Ratify pending proposals of ANY kind — decisions, facts, and domains share one gate.
1785
+
1786
+ Each id in ``accept``/``drop`` is routed by lookup: a pending decision flips
1787
+ proposed->accepted (or rejected on drop, append-only); a pending fact flips the same
1788
+ way directly; a pending domain flips proposed->accepted and mints its paired
1789
+ ``domain:<slug>`` entity (or ->dropped, no entity minted). An id present in both lists
1790
+ is accepted; the drop is ignored (not a conflict — reported as ``"accepted (drop
1791
+ ignored)"``/etc). Unknown ids get an ``"error: ..."`` entry; one bad id never aborts the
1792
+ rest of the batch.
1793
+
1794
+ Cascade: accepting/dropping a decision id also flips every still-proposed fact that
1795
+ supports it (facts layer 2026-07-10) — each cascaded fact id gets its OWN entry in the
1796
+ returned dict too, ``f"accepted (evidence of {decision_id})"`` /
1797
+ ``f"dropped (evidence of {decision_id})"``, so nothing this call touched goes
1798
+ unreported.
1799
+ """
1800
+ return _ratify_impl(_get_store(), accept=accept, drop=drop, reader=_load_reader())
1801
+
1802
+
1803
+ @mcp.tool
1804
+ def ratify_decisions(
1805
+ accept: list[str] | None = None, drop: list[str] | None = None
1806
+ ) -> dict[str, str]:
1807
+ """Deprecated alias for ``ratify`` (kept for one release; despite the name, it now
1808
+ covers facts and domains too — identical behavior to ``ratify``). Prefer ``ratify``."""
1809
+ return _ratify_impl(_get_store(), accept=accept, drop=drop, reader=_load_reader())
1810
+
1811
+
1812
+ def _add_domain_impl(
1813
+ store: Store,
1814
+ reader,
1815
+ slug: str,
1816
+ title: str,
1817
+ summary: str,
1818
+ parent_slug: str | None = None,
1819
+ path_prefixes: list[str] | None = None,
1820
+ communities: list[str] | None = None,
1821
+ seed_anchors: list[dict] | None = None,
1822
+ author: str | None = "agent",
1823
+ ) -> dict:
1824
+ """Testable core for add_domain (§4.3, manual path). Always lands `status=proposed` —
1825
+ manual authoring is not an exception to the ratification gate (§4: "one gate, no
1826
+ exceptions")."""
1827
+ parent_id = None
1828
+ if parent_slug is not None:
1829
+ parent = store.find_domain_by_slug(parent_slug)
1830
+ if parent is None:
1831
+ raise ValueError(f"parent_slug {parent_slug!r} does not resolve to any domain")
1832
+ parent_id = parent.domain_id
1833
+
1834
+ graph_version = reader.graph_version() if reader is not None else None
1835
+ domain = Domain(
1836
+ slug=slug,
1837
+ title=title,
1838
+ summary=summary,
1839
+ parent_id=parent_id,
1840
+ communities=communities or [],
1841
+ path_prefixes=path_prefixes or [],
1842
+ # raw MCP JSON dicts -> Descriptor; pydantic validates/coerces each on construction.
1843
+ seed_anchors=[Descriptor(**d) for d in seed_anchors] if seed_anchors else [],
1844
+ provenance=Provenance(source="manual", author=author, graph_version=graph_version),
1845
+ )
1846
+ store.add_domain(domain)
1847
+ return {"domain_id": domain.domain_id, "status": domain.status.value}
1848
+
1849
+
1850
+ @mcp.tool
1851
+ def add_domain(
1852
+ slug: str,
1853
+ title: str,
1854
+ summary: str,
1855
+ parent_slug: str | None = None,
1856
+ path_prefixes: list[str] | None = None,
1857
+ communities: list[str] | None = None,
1858
+ seed_anchors: list[dict] | None = None,
1859
+ author: str | None = "agent",
1860
+ ) -> dict:
1861
+ """Manually author a Domain — a named area of the system with WHY-IT-EXISTS prose
1862
+ (§4.3, manual path). Always lands ``status=proposed``: manual authoring is not an
1863
+ exception to the ratification gate — ``ratify``/``sidegraph-ratify`` accepts it like any
1864
+ other draft.
1865
+
1866
+ ``parent_slug``, when given, must resolve to an existing (non-superseded) domain via
1867
+ ``find_domain_by_slug``; anything else is a hard error (never guess a parent).
1868
+ ``path_prefixes`` is a static stabilizer rule, set here and never touched again;
1869
+ ``seed_anchors`` (``[{"name", "file_path"?}, ...]``) is the durable, entity-anchored
1870
+ counterpart (§2a amendment) — both are resolved into ``communities`` by
1871
+ ratify/sync, never the other way around. ``communities`` remains as a separate,
1872
+ optional immediate seed for a direct-write caller that already knows current
1873
+ (volatile) community ids and wants them visible before the next resolve pass.
1874
+
1875
+ Returns ``{"domain_id", "status"}``.
1876
+ """
1877
+ return _add_domain_impl(
1878
+ _get_store(),
1879
+ _load_reader(),
1880
+ slug,
1881
+ title,
1882
+ summary,
1883
+ parent_slug=parent_slug,
1884
+ path_prefixes=path_prefixes,
1885
+ communities=communities,
1886
+ seed_anchors=seed_anchors,
1887
+ author=author,
1888
+ )
1889
+
1890
+
1891
+ def _resolve_domain_ref(store: Store, slug_or_id: str) -> Domain | None:
1892
+ """``old_slug_or_id`` may be either a ``domain_id`` (ULID) or a ``slug`` — try the id
1893
+ lookup first (exact, cheap), then fall back to ``find_domain_by_slug`` (which already
1894
+ prefers accepted > proposed > dropped, newest first) so a caller of
1895
+ ``supersede_domain`` doesn't need to know or track which shape it's holding."""
1896
+ domain = store.get_domain(slug_or_id)
1897
+ if domain is not None:
1898
+ return domain
1899
+ return store.find_domain_by_slug(slug_or_id)
1900
+
1901
+
1902
+ def _supersede_domain_impl(
1903
+ store: Store,
1904
+ reader,
1905
+ old_slug_or_id: str,
1906
+ new_slug: str,
1907
+ new_title: str,
1908
+ new_summary: str,
1909
+ path_prefixes: list[str] | None = None,
1910
+ seed_anchors: list[dict] | None = None,
1911
+ parent_slug: str | None = None,
1912
+ author: str | None = "agent",
1913
+ ) -> dict:
1914
+ """Testable core for supersede_domain: the lineage-correct rename/re-scope path.
1915
+
1916
+ Wraps the existing ``Store.supersede_domain`` primitive (append-only reversal: close
1917
+ the old domain, write a new one with ``supersedes`` set, in one transaction) with the
1918
+ same manual-authoring shape ``_add_domain_impl`` uses — ``parent_slug`` resolution,
1919
+ ``path_prefixes``/``seed_anchors`` as the successor's membership-rule seed, manual
1920
+ provenance. Like every other domain-authoring path, the successor lands
1921
+ ``status=proposed`` -- domains have no exception to the one ratification gate (see
1922
+ ``_add_domain_impl``'s own docstring): closing the predecessor happens immediately
1923
+ (that's what "supersede" means), but the new name/scope still needs a human `ratify`
1924
+ before it's TOC-visible.
1925
+ """
1926
+ old = _resolve_domain_ref(store, old_slug_or_id)
1927
+ if old is None:
1928
+ raise ValueError(f"old_slug_or_id {old_slug_or_id!r} does not resolve to any domain")
1929
+
1930
+ parent_id = None
1931
+ if parent_slug is not None:
1932
+ parent = store.find_domain_by_slug(parent_slug)
1933
+ if parent is None:
1934
+ raise ValueError(f"parent_slug {parent_slug!r} does not resolve to any domain")
1935
+ parent_id = parent.domain_id
1936
+
1937
+ graph_version = reader.graph_version() if reader is not None else None
1938
+ new_domain = Domain(
1939
+ slug=new_slug,
1940
+ title=new_title,
1941
+ summary=new_summary,
1942
+ parent_id=parent_id,
1943
+ path_prefixes=path_prefixes or [],
1944
+ # raw MCP JSON dicts -> Descriptor; pydantic validates/coerces each on construction.
1945
+ seed_anchors=[Descriptor(**d) for d in seed_anchors] if seed_anchors else [],
1946
+ supersedes=old.domain_id,
1947
+ provenance=Provenance(source="manual", author=author, graph_version=graph_version),
1948
+ )
1949
+ result = store.supersede_domain(old.domain_id, new_domain)
1950
+ return {
1951
+ "domain_id": result.domain_id,
1952
+ "status": result.status.value,
1953
+ "supersedes": old.domain_id,
1954
+ }
1955
+
1956
+
1957
+ @mcp.tool
1958
+ def supersede_domain(
1959
+ old_slug_or_id: str,
1960
+ new_slug: str,
1961
+ new_title: str,
1962
+ new_summary: str,
1963
+ path_prefixes: list[str] | None = None,
1964
+ seed_anchors: list[dict] | None = None,
1965
+ parent_slug: str | None = None,
1966
+ author: str | None = "agent",
1967
+ ) -> dict:
1968
+ """Close an old Domain and write its replacement — the lineage-correct rename/re-scope
1969
+ path (mirrors ``supersede_decision`` for the domain side; wraps the existing
1970
+ ``Store.supersede_domain`` primitive, which previously had no MCP surface).
1971
+
1972
+ ``old_slug_or_id`` resolves either a ``domain_id`` or a ``slug`` (tries the id lookup
1973
+ first, then ``find_domain_by_slug``) — never a guess: an id/slug that resolves to
1974
+ nothing is a hard error. ``parent_slug``, when given, must resolve to an existing
1975
+ (non-superseded) domain, same as ``add_domain``'s. ``path_prefixes``/``seed_anchors``
1976
+ seed the SUCCESSOR's membership rule from scratch (nothing is inherited from the
1977
+ predecessor — pass the old domain's own values back if you want them carried over).
1978
+
1979
+ The predecessor is flipped to ``superseded`` immediately (append-only: the record
1980
+ stays, fully retrievable, never deleted) in the same transaction that writes the
1981
+ successor. The successor itself always lands ``status=proposed`` — same "one gate, no
1982
+ exceptions" rule every other domain-authoring tool follows (``add_domain``,
1983
+ ``propose_domains``): a human still calls ``ratify(accept=[...])`` before the new
1984
+ name/scope is TOC-visible.
1985
+
1986
+ Raises (before anything is written) if: ``old_slug_or_id`` doesn't resolve to any
1987
+ domain; ``parent_slug`` is given but doesn't resolve to any domain; or ``new_slug``
1988
+ collides with some OTHER still-live (proposed/accepted) domain (the predecessor itself
1989
+ is excluded from that check, so reusing the same slug is fine).
1990
+
1991
+ Returns ``{"domain_id": str, "status": str, "supersedes": str}`` — ``status`` is
1992
+ always ``"proposed"``, ``domain_id`` is the successor's, ``supersedes`` is the
1993
+ predecessor's resolved ``domain_id``.
1994
+ """
1995
+ return _supersede_domain_impl(
1996
+ _get_store(),
1997
+ _load_reader(),
1998
+ old_slug_or_id,
1999
+ new_slug,
2000
+ new_title,
2001
+ new_summary,
2002
+ path_prefixes=path_prefixes,
2003
+ seed_anchors=seed_anchors,
2004
+ parent_slug=parent_slug,
2005
+ author=author,
2006
+ )
2007
+
2008
+
2009
+ def _propose_domains_impl(
2010
+ store,
2011
+ reader,
2012
+ drafts: list[dict],
2013
+ session_id: str | None = None,
2014
+ author: str | None = None,
2015
+ ratify_policy: RatifyPolicy = RatifyPolicy.MANUAL,
2016
+ ) -> list[dict]:
2017
+ """Testable core for propose_domains (see capture.propose_domains).
2018
+
2019
+ ``ratify_policy`` (default ``RatifyPolicy.MANUAL``) is the resolved
2020
+ ``SIDEGRAPH_RATIFY_POLICY`` value (design D1, ``server._ratify_policy()``), forwarded
2021
+ unchanged to ``capture.propose_domains``.
2022
+ # see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D1
2023
+ """
2024
+ results = _propose_domain_drafts(
2025
+ drafts, store, reader, session_id=session_id, author=author, ratify_policy=ratify_policy
2026
+ )
2027
+ return [r.model_dump(mode="json") for r in results]
2028
+
2029
+
2030
+ @mcp.tool
2031
+ def propose_domains(
2032
+ drafts: list[dict],
2033
+ session_id: str | None = None,
2034
+ author: str | None = None,
2035
+ ) -> list[dict]:
2036
+ """Propose Domain drafts recognized during this session (§4.2, agent in-session path) —
2037
+ mirrors ``propose_decisions`` for the domain side.
2038
+
2039
+ Each draft: {"slug", "title", "summary", "parent_slug"?, "path_prefixes"?,
2040
+ "seed_anchors"?}. ``seed_anchors`` (``[{"name", "file_path"?}, ...]``) is a durable
2041
+ entity-anchor seed (§2a amendment; mirrors ``add_domain``'s own param) for an
2042
+ agent-curated merge that has no single clean shared path prefix to rely on — resolves
2043
+ into ``communities`` on ratify (immediately) and on every later ``sync`` pass, so
2044
+ membership survives a fresh clone or a graph rebuild instead of evaporating like a raw
2045
+ community id would. May be given alongside ``path_prefixes``, in place of it, or
2046
+ omitted. The pipeline redacts secrets from title/summary, skips (never overwrites) when
2047
+ a non-superseded domain already claims the slug, resolves ``parent_slug`` (error if it
2048
+ doesn't resolve), and writes as status=proposed — a human ratifies later via
2049
+ ``ratify``/``sidegraph-ratify``, unless SIDEGRAPH_RATIFY_POLICY=auto-all ratifies an
2050
+ eligible draft at write time (``ratified_by`` carries the ``auto:<policy>`` stamp) and
2051
+ resolves its membership immediately. When that membership step hits a problem, the domain
2052
+ stays accepted anyway, and ``auto_ratify_error`` opens with ``activation:`` followed by
2053
+ one of two things: the error that stopped membership from resolving, or a ``path rule
2054
+ too broad`` notice, which means the ``path_prefixes`` claim was rejected and only the
2055
+ ``seed_anchors`` that resolve, if any, are still applied.
2056
+
2057
+ Each result also carries ``warnings`` (design D7.4, deterministic domain lint) — a
2058
+ ``path_prefix`` matching no file in the current graph ("dead prefix"), or one that
2059
+ would subsume another ACCEPTED domain's own ``seed_anchors`` file. Advisory only,
2060
+ never blocks the write; under ``auto-all`` any warning keeps the draft proposed; empty
2061
+ when ``path_prefixes`` is empty or every prefix passes both checks.
2062
+ """
2063
+ return _propose_domains_impl(
2064
+ _get_store(),
2065
+ _load_reader(),
2066
+ drafts,
2067
+ session_id=session_id,
2068
+ author=author,
2069
+ ratify_policy=_ratify_policy(),
2070
+ )
2071
+
2072
+
2073
+ def _compact_candidate(candidate) -> dict:
2074
+ """One ``DomainCandidate`` rendered as ``list_domain_candidates``'s per-candidate
2075
+ output shape (§1 design) — deliberately field-renamed/thinned from the internal model
2076
+ (``community_id`` -> ``community``, ``member_count`` -> ``members``) to match the
2077
+ tool's public contract, not the collector's internal one."""
2078
+ return {
2079
+ "community": candidate.community_id,
2080
+ "suggested_slug": candidate.suggested_slug,
2081
+ "suggested_title": candidate.suggested_title,
2082
+ "members": candidate.member_count,
2083
+ "top_members": candidate.top_members,
2084
+ "top_file": candidate.top_file,
2085
+ "has_label": candidate.has_label,
2086
+ # Durable anchor (§2a amendment): the community's god-node as a name+file_path
2087
+ # Descriptor -- feed this back as a Domain.seed_anchors entry instead of the
2088
+ # volatile `community` id above, which does not survive a fresh clone/rebuild.
2089
+ "anchor": candidate.anchor.model_dump() if candidate.anchor else None,
2090
+ }
2091
+
2092
+
2093
+ def _list_domain_candidates_impl(
2094
+ store: Store,
2095
+ reader,
2096
+ min_members: int = 5,
2097
+ paths: list[str] | None = None,
2098
+ limit: int | None = None,
2099
+ ) -> dict:
2100
+ """Testable core for list_domain_candidates (§1 design). Pure read: builds on
2101
+ ``collect_domain_candidates`` (the exact selection ``bootstrap_domains`` would write)
2102
+ and only reads ``reader.communities()`` again to derive each candidate's presentational
2103
+ grouping path — never touches the store's write path.
2104
+
2105
+ ``limit`` here is already resolved to ``collect_domain_candidates``'s own convention
2106
+ (``None`` = unlimited) — the public ``0``-means-unlimited sentinel and the scale-aware
2107
+ default (``DEFAULT_CANDIDATE_LIMIT``, finding B) are the outer ``list_domain_candidates``
2108
+ tool's job to apply/translate, so this "testable core" stays a thin, default-agnostic
2109
+ pass-through, same division of labor as ``collect_domain_candidates`` itself.
2110
+
2111
+ Best-effort like every other MCP tool here: with no graph present, returns an
2112
+ all-empty shape (a ``"note"`` explains why) instead of erroring.
2113
+ """
2114
+ if reader is None:
2115
+ return {
2116
+ "graph_version": None,
2117
+ "total_candidates": 0,
2118
+ "total_significant": 0,
2119
+ "truncated": False,
2120
+ "already_claimed": 0,
2121
+ "skipped": {"below_threshold": 0, "filtered": 0},
2122
+ "groups": [],
2123
+ "ungrouped": [],
2124
+ "note": "no graphify graph present",
2125
+ }
2126
+
2127
+ candidates, stats = collect_domain_candidates(
2128
+ store, reader, min_members=min_members, paths=paths, limit=limit
2129
+ )
2130
+ # Built once, reused per candidate — community_group_path's own per-call fallback
2131
+ # would otherwise re-walk reader.communities() for every candidate needing a fallback.
2132
+ communities_by_id = {c.community_id: c for c in reader.communities()}
2133
+
2134
+ groups: dict[str, list[dict]] = {}
2135
+ ungrouped: list[dict] = []
2136
+ for c in candidates:
2137
+ compact = _compact_candidate(c)
2138
+ path = c.path_prefixes[0] if c.path_prefixes else None
2139
+ if path is None:
2140
+ path = community_group_path(c.community_id, reader, communities_by_id)
2141
+ if path is None:
2142
+ ungrouped.append(compact)
2143
+ else:
2144
+ groups.setdefault(path, []).append(compact)
2145
+
2146
+ groups_out = [
2147
+ {
2148
+ "path": path,
2149
+ "member_total": sum(c["members"] for c in members),
2150
+ "candidates": members,
2151
+ }
2152
+ for path, members in sorted(groups.items())
2153
+ ]
2154
+
2155
+ # finding B: `limit` truncates when it's set AND there were more significant
2156
+ # candidates than it let through -- independent of `already_claimed`, which only ever
2157
+ # narrows the (possibly already-limited) survivor set further, never the reverse.
2158
+ truncated = limit is not None and stats.total_before_limit > limit
2159
+ result = {
2160
+ "graph_version": reader.graph_version(),
2161
+ "total_candidates": stats.total,
2162
+ "total_significant": stats.total_before_limit,
2163
+ "already_claimed": stats.already_claimed,
2164
+ "skipped": {"below_threshold": stats.below_threshold, "filtered": stats.filtered},
2165
+ "groups": groups_out,
2166
+ "ungrouped": ungrouped,
2167
+ "truncated": truncated,
2168
+ }
2169
+ if truncated:
2170
+ result["note"] = (
2171
+ f"showing the top {limit} of {stats.total_before_limit} significant candidates "
2172
+ "(community-id order) -- widen with an explicit limit=N, limit=0 for the full "
2173
+ "list, or narrow with min_members/paths"
2174
+ )
2175
+ return result
2176
+
2177
+
2178
+ @mcp.tool
2179
+ def list_domain_candidates(
2180
+ min_members: int = 5,
2181
+ paths: list[str] | None = None,
2182
+ limit: int = DEFAULT_CANDIDATE_LIMIT,
2183
+ ) -> dict:
2184
+ """Read-only projection of the bootstrap candidate machinery (§1 domain-onboarding
2185
+ design) — the machine half of the ``name-domains`` skill. WRITES NOTHING, EVER; safe to
2186
+ call repeatedly.
2187
+
2188
+ Presents every significant, not-yet-claimed community as a naming candidate,
2189
+ pre-grouped by shared top-level path (a structure hint the agent is free to regroup,
2190
+ merge, or rename). Built on ``collect_domain_candidates`` — the exact same selection
2191
+ ``sidegraph-domains bootstrap`` would write — so this tool always shows exactly what
2192
+ the CLI would propose, with every one of bootstrap's guards already applied (label-
2193
+ mismatch rejection, well-known-shared-dir/breadth veto on ``path_prefixes``, claim
2194
+ skip, within-run slug dedup, redact-before-slugify).
2195
+
2196
+ ``min_members``/``paths`` mirror ``sidegraph-domains bootstrap``'s own knobs to
2197
+ pre-narrow when wanted; the ``name-domains`` skill's default call omits both and lets
2198
+ the agent narrow in conversation instead.
2199
+
2200
+ ``limit`` (finding B, scale-robustness hardening) defaults to 100 — the top 100
2201
+ significant communities, in deterministic community-id order, same ordering
2202
+ ``sidegraph-domains bootstrap`` applies its own ``--limit`` in. On a monorepo-scale
2203
+ corpus the unbounded list is a ~276K-token dump (Airflow: 2,578 candidates); 100 is the
2204
+ measured sweet spot (~10K tokens). Pass an explicit ``limit=N`` to widen it, or
2205
+ ``limit=0`` for the full, unbounded list when you really want everything (the "all"
2206
+ convention — mirrors ``sidegraph-domains bootstrap --limit 0``). When the effective
2207
+ limit actually cuts candidates, the response's ``truncated`` is ``True`` and
2208
+ ``total_significant`` names the FULL count so it's never mistaken for the whole graph
2209
+ — narrow with ``min_members``/``paths`` instead, or widen ``limit``, rather than assume
2210
+ this is everything. Note: re-running with the SAME default/explicit limit only ever
2211
+ proposes the same community-id-sorted window (communities beyond it are never reached
2212
+ until you widen).
2213
+
2214
+ Returns ``{"graph_version", "total_candidates", "total_significant", "truncated",
2215
+ "already_claimed", "skipped": {"below_threshold", "filtered"}, "groups": [{"path",
2216
+ "member_total", "candidates": [{"community", "suggested_slug", "suggested_title",
2217
+ "members", "top_members" (<=3), "top_file", "has_label", "anchor"}, ...]}],
2218
+ "ungrouped": [...same candidate shape...]}``. ``total_candidates`` is how many
2219
+ candidates THIS response actually includes (post-limit, post-already_claimed);
2220
+ ``total_significant`` is how many significant communities exist in total, before
2221
+ ``limit`` truncated them AND before the separate ``already_claimed`` skip — the two can
2222
+ differ even when ``truncated`` is ``False`` (some of what ``limit`` let through was
2223
+ already claimed); ``truncated`` specifically means "the limit itself cut candidates you
2224
+ never even got to see."
2225
+
2226
+ ``anchor`` (``{"name", "file_path"}`` or ``null``, §2a amendment) is the community's
2227
+ god-node resolved to a durable Descriptor — feed it back as a ``Domain.seed_anchors``
2228
+ entry (via ``propose_domains``/``add_domain``) instead of the volatile ``community`` id
2229
+ alone, which does NOT survive a fresh clone or a graph rebuild.
2230
+
2231
+ A candidate groups under its own derived ``path_prefixes`` when it has one; else under
2232
+ a clear (>=80%) majority top-level directory among its members — the same majority
2233
+ calc ``path_prefixes`` derivation uses, minus its two stabilizer-only vetoes (this is a
2234
+ display hint, never a membership rule). A candidate with neither lands in
2235
+ ``ungrouped``, never silently dropped. ``already_claimed`` counts communities excluded
2236
+ because a non-superseded domain (or a slug collision) already claims them — never
2237
+ listed in ``groups``/``ungrouped``.
2238
+ """
2239
+ resolved_limit = None if limit == 0 else limit
2240
+ return _list_domain_candidates_impl(
2241
+ _get_store(), _load_reader(), min_members=min_members, paths=paths, limit=resolved_limit
2242
+ )
2243
+
2244
+
2245
+ def _list_domains_impl(store: Store, status: str | None = None) -> list[dict]:
2246
+ """Testable core for list_domains: every domain in the store (optionally filtered by
2247
+ status), sorted by slug. Pure read -- no reader/graph needed, writes nothing.
2248
+
2249
+ Parent/child relationships are computed from the FULL, unfiltered domain set (never
2250
+ just the filtered slice being returned) so e.g. ``status="accepted"`` still reports an
2251
+ accepted child's proposed parent correctly, instead of silently losing the link.
2252
+ """
2253
+ status_enum = DomainStatus(status) if status is not None else None
2254
+ all_domains = list(store.iter_domains())
2255
+ by_id = {d.domain_id: d for d in all_domains}
2256
+ children_by_parent: dict[str, list[str]] = {}
2257
+ for d in all_domains:
2258
+ if d.parent_id:
2259
+ children_by_parent.setdefault(d.parent_id, []).append(d.slug)
2260
+
2261
+ selected = (
2262
+ all_domains if status_enum is None else [d for d in all_domains if d.status == status_enum]
2263
+ )
2264
+
2265
+ out = []
2266
+ for d in sorted(selected, key=lambda d: d.slug):
2267
+ parent = by_id.get(d.parent_id) if d.parent_id else None
2268
+ out.append(
2269
+ {
2270
+ "id": d.domain_id,
2271
+ "slug": d.slug,
2272
+ "title": d.title,
2273
+ "summary": d.summary,
2274
+ "status": d.status.value,
2275
+ "member_count": len(d.communities),
2276
+ "path_prefixes": d.path_prefixes,
2277
+ "seed_anchor_count": len(d.seed_anchors),
2278
+ "parent_slug": parent.slug if parent else None,
2279
+ "child_slugs": sorted(children_by_parent.get(d.domain_id, [])),
2280
+ }
2281
+ )
2282
+ return out
2283
+
2284
+
2285
+ @mcp.tool
2286
+ def list_domains(status: str | None = None) -> list[dict]:
2287
+ """List every Domain in the store — the full-listing counterpart to ``list_proposed``
2288
+ (proposed-only) and ``list_domain_candidates`` (unclaimed-only): the tool that
2289
+ actually answers "show me all domains".
2290
+
2291
+ ``status``, when given, filters to one of ``"proposed"``/``"accepted"``/
2292
+ ``"dropped"``/``"superseded"``; omitted (the default) returns every domain regardless
2293
+ of status. Read-only — writes nothing, ever; safe to call repeatedly.
2294
+
2295
+ Returns a list sorted by ``slug``, one dict per domain: ``{"id": str, "slug": str,
2296
+ "title": str, "summary": str, "status": str, "member_count": int, "path_prefixes":
2297
+ list[str], "seed_anchor_count": int, "parent_slug": str | None, "child_slugs":
2298
+ list[str]}``. ``member_count`` is ``len(domain.communities)`` — the current, engine-
2299
+ derived membership size (0 until the next `ratify`/`sidegraph-sync` resolves
2300
+ `path_prefixes`/`seed_anchors`, for a freshly proposed domain). ``parent_slug``/
2301
+ ``child_slugs`` reflect the FULL domain set regardless of the ``status`` filter, so a
2302
+ filtered call still reports accurate lineage.
2303
+ """
2304
+ return _list_domains_impl(_get_store(), status=status)
2305
+
2306
+
2307
+ def _drill_down_impl(store: Store, reader, domain_slug: str) -> dict:
2308
+ """Testable core for drill_down (§5 Axis-1 operation).
2309
+
2310
+ Records telemetry only on a found domain — an unknown slug renders no decision memory,
2311
+ so there is nothing to call a "show". ``decision_ids`` is popped before returning: it
2312
+ exists on the ``retrieval.drill_down`` result purely so this wrapper can record it, and
2313
+ is not part of the documented MCP tool contract (see the ``drill_down`` tool docstring).
2314
+ The seed recorded is the domain itself (``domain:<slug>``, the same key convention
2315
+ ``domain:<slug>`` abstract entities already use elsewhere in this store) — a drill-down
2316
+ has no file/entity seeds the way get_task_context/query_decisions do.
2317
+ """
2318
+ result = _drill_down(domain_slug, store, reader)
2319
+ decision_ids = result.pop("decision_ids", [])
2320
+ if result.get("found"):
2321
+ _record(store, decision_ids, [f"domain:{domain_slug}"])
2322
+ return result
2323
+
2324
+
2325
+ @mcp.tool
2326
+ def drill_down(domain_slug: str) -> dict:
2327
+ """Walk one domain: its WHY-IT-EXISTS summary, its accepted subdomains (title +
2328
+ one-liner), a capped member sample (current communities ∪ path_prefixes), and its
2329
+ decisions (mistakes first) — the union, deduped, of decisions tagged to the
2330
+ ``domain:<slug>`` entity, decisions anchored to an entity in one of the domain's
2331
+ communities, AND decisions anchored to a document whose file the domain covers (so an
2332
+ imported ADR surfaces under the domain covering that doc's headings, even on a doc
2333
+ corpus where the file node hubs into a different community) — the Axis-1 counterpart to
2334
+ the flat SessionStart TOC (call this after spotting a domain there to go one level deeper).
2335
+
2336
+ Returns ``{"found": True, "domain": {"slug", "title", "summary", "parent_slug",
2337
+ "status"}, "subdomains": [{"slug", "title", "summary"}, ...], "members": [rendered
2338
+ node lines], "decisions": [rendered decision lines, mistakes first]}``. ``status`` is
2339
+ the resolved domain's own status (proposed|accepted|dropped — ``find_domain_by_slug``
2340
+ never resolves to a superseded row) since a caller may drill into a not-yet-ratified
2341
+ domain.
2342
+
2343
+ Unknown ``domain_slug`` -> ``{"found": False, "candidates": [...]}`` with up to 10
2344
+ currently-accepted slugs to retry with (never a guess). ``members`` is empty (with a
2345
+ ``"note"`` key) when no Graphify graph is present — everything else still returns.
2346
+
2347
+ A decision line may carry a ``[drifted]`` tag (the code it is anchored to changed
2348
+ after it was captured); when at least one does, the result also carries a ``"legend"``
2349
+ key explaining the tag — verify such records against the current code and
2350
+ ``supersede_decision`` any that no longer hold. (Deliberate contract addition,
2351
+ drift→supersede wave N3.)
2352
+ """
2353
+ return _drill_down_impl(_get_store(), _synced_reader(), domain_slug)
2354
+
2355
+
2356
+ def _sync_anchors_impl(store: Store, reader: GraphifyReader | None, force: bool = False) -> dict:
2357
+ """Testable core for sync_anchors (Gap 3, design/superpowers/specs/
2358
+ 2026-07-10-ratification-ux-and-mcp-gaps-design.md) -- the diagnostic/heal path.
2359
+
2360
+ Unlike ``_synced_reader`` (the silent lazy path every retrieval tool -- get_task_context/
2361
+ query_structure/query_decisions/drill_down -- shares: exceptions suppressed, no
2362
+ report), this never swallows a sync failure quietly. The caller builds ``reader`` via
2363
+ its own ``_load_reader()`` and hands it in explicitly; ``None`` means the graph
2364
+ couldn't be read at all, reported as an explanatory error rather than degrading.
2365
+
2366
+ Runs the SAME ``sync(store, reader, force=force)`` ``sidegraph-sync`` runs (see
2367
+ ``cli.sync_main``) and hands its ``SyncReport`` to ``sync.report_as_dict`` -- the
2368
+ shared shape ``sidegraph-sync --json`` also prints (design/superpowers/specs/
2369
+ 2026-07-11-ci-integrity-design.md ruling 1) -- instead of building it inline.
2370
+ """
2371
+ if reader is None:
2372
+ return {"synced": False, "error": f"graph not readable ({_graph_path()})"}
2373
+
2374
+ report = sync(store, reader, force=force)
2375
+ return report_as_dict(report)
2376
+
2377
+
2378
+ @mcp.tool
2379
+ def sync_anchors(force: bool = False) -> dict:
2380
+ """Re-anchor the decision store against the current graph and report exactly what
2381
+ happened -- the diagnostic/heal MCP counterpart to ``sidegraph-sync`` (Gap 3).
2382
+
2383
+ WRITES: this is not read-only. It runs the same rebind pass ``sidegraph-sync``/the
2384
+ lazy ``maybe_sync`` run -- every tracked entity's tier-2 leaf bindings transition
2385
+ (live/degraded/orphaned) per the deterministic resolve ladder, community (tier-1)
2386
+ bindings get re-pointed when Leiden renumbered, and every ACCEPTED domain's
2387
+ ``communities`` are refreshed from its ``path_prefixes``/``seed_anchors``. The
2388
+ entity's own canonical DESCRIPTOR is rewritten ONLY on a "moved" rung (a unique
2389
+ name-only match after the exact match missed); the node-id mapping
2390
+ (``last_seen_node_id``/``last_seen_community``/``last_seen_graph_version``, via
2391
+ ``sync.py``'s ``_adopt``) updates on that same "moved" rung AND on an exact-match
2392
+ "rebound" rung (same ``name``+``file_path`` descriptor match as last sync, but the
2393
+ resolved node id CHANGED since -- see the rebind ladder in
2394
+ docs/guides/surviving-refactors.md) -- never guessed on "ambiguous" or "orphaned".
2395
+ These are the SAME writes ``sidegraph-sync`` makes; this tool just surfaces the
2396
+ report as data instead of printing it to stdout.
2397
+
2398
+ This is the diagnostic path -- unlike every retrieval tool here (get_task_context/
2399
+ query_structure/query_decisions/drill_down), which sync lazily and SILENTLY (a sync
2400
+ failure there just degrades to un-synced retrieval; nothing is ever reported), call
2401
+ this after a Graphify rebuild when you want to SEE the rebind ladder's outcomes, not
2402
+ just quietly benefit from them.
2403
+
2404
+ Gated on ``graph_version`` vs the store's last-synced stamp, same as
2405
+ ``sidegraph-sync`` -- but also reruns on its own, even when the version already
2406
+ matches, the first time it's called after a canonical reload (``git pull``, merge,
2407
+ branch switch) leaves the store's volatile state cold; ``force=True`` still forces an
2408
+ unconditional rerun (e.g. after hand-editing a domain's ``path_prefixes``).
2409
+
2410
+ Returns ``{"synced": bool, "from_version": str | None, "to_version": str, "counts":
2411
+ str, "repointed": int, "outcomes": [{"status", "canonical_name", "detail"}, ...],
2412
+ "stale_decisions": [...], "empty_domains": [...], "overbroad_domains": [...],
2413
+ "slug_conflicts": [...], "domains_refreshed": int, "domain_failures": [{"slug",
2414
+ "title", "error"}, ...]}``. ``synced`` is ``False`` when the pass was skipped outright
2415
+ (``graph_version`` unchanged, no ``force``, and no cold-reload flag pending) -- when
2416
+ skipped, every OTHER field is an EMPTY default (``outcomes: []``, ``counts: ""``,
2417
+ ``repointed: 0``, ``stale_decisions: []``, ``empty_domains: []``, ``overbroad_domains: []``,
2418
+ ``slug_conflicts: []``, ``domains_refreshed: 0``, ``domain_failures: []``) from a
2419
+ fresh, un-run ``SyncReport(skipped=True)`` -- NOT the prior (possibly stale) report --
2420
+ so a caller must never read a skipped pass as "everything's clean"; pass
2421
+ ``force=True`` (or wait for a real graph rebuild) to get an actual report. ``outcomes``
2422
+ carries only entities worth a human's attention -- moved/ambiguous/orphaned/error --
2423
+ never the "unchanged"/"rebound" majority, same filter ``sidegraph-sync``'s own printer
2424
+ applies. ``counts`` is ``report.counts()`` rendered as a string (e.g.
2425
+ ``"{'unchanged': 3}"``), ``""`` when nothing is tracked yet. ``domain_failures`` is the
2426
+ domain-refresh analog of an ``error`` outcome -- one entry per accepted domain whose
2427
+ refresh itself raised, isolated so one broken domain never costs any other domain its
2428
+ heal; it is an attention finding for ``--check``/``report_has_findings``, unlike the
2429
+ informational ``empty_domains``/``overbroad_domains``.
2430
+
2431
+ With no Graphify graph present, returns ``{"synced": False, "error": "graph not
2432
+ readable (<resolved path>)"}`` instead of crashing -- explanatory, not silent, since
2433
+ this IS the diagnostic tool (contrast every other tool's best-effort, no-graph-present
2434
+ degrade, which never surfaces an error at all).
2435
+ """
2436
+ return _sync_anchors_impl(_get_store(), _load_reader(), force=force)
2437
+
2438
+
2439
+ def _verify_store_impl(store: Store) -> dict:
2440
+ """Testable core for verify_store (design/superpowers/specs/
2441
+ 2026-07-11-ci-integrity-design.md ruling 2, snapshot layer). Takes the already-open
2442
+ ``store`` and reads its own ``.path`` rather than re-resolving ``SIDEGRAPH_DIR`` or
2443
+ constructing a second ``Store`` -- the MCP tool below already went through
2444
+ ``_get_store()`` for every other tool in this module, and that Store object already
2445
+ knows its own root.
2446
+
2447
+ Delegates straight to ``verify.verify_snapshot`` -- a pure read over the canonical
2448
+ JSON files (never ``index.db``, never a write) -- and reshapes its
2449
+ ``list[Violation]`` into the tool's public dict contract.
2450
+ """
2451
+ violations = verify_snapshot(store.path)
2452
+ return {
2453
+ "clean": not violations,
2454
+ "violations": [{"code": v.code, "path": v.path, "detail": v.detail} for v in violations],
2455
+ }
2456
+
2457
+
2458
+ @mcp.tool
2459
+ def verify_store() -> dict:
2460
+ """Lint the decision store's canonical files against its write-path invariants — the
2461
+ MCP counterpart to ``sidegraph-verify`` (design/superpowers/specs/
2462
+ 2026-07-11-ci-integrity-design.md ruling 2).
2463
+
2464
+ READ-ONLY: this never writes anything, never touches ``index.db``, and never migrates
2465
+ a legacy store -- it opens the canonical JSON files directly, the exact same pure-read
2466
+ pass ``sidegraph-verify`` runs without ``--against``.
2467
+
2468
+ Checks (snapshot layer, always everything below): every hot record file parses
2469
+ against its schema; ``schema_version`` is present and known; ``valid_to >=
2470
+ valid_from``; a ``superseded`` record has a successor (its ``supersedes`` chain
2471
+ resolves); every ``supersedes`` target exists; every binding references an existing
2472
+ entity; every fact ``supports`` references an existing record; ULIDs are unique
2473
+ across hot files AND archive segments (byte-IDENTICAL archive-archive duplicates from
2474
+ a sanctioned cross-branch ``sidegraph-compact`` merge are exempt); archive segments
2475
+ parse as JSONL; every hot record file is named ``<its own internal id>.json``.
2476
+
2477
+ Snapshot-only in v1 — this tool takes no git ref. CI users who also want the
2478
+ transition layer (classify every store file that changed vs a git ref against the
2479
+ store's OWN write rules -- what's legally mutable per record kind) should run
2480
+ ``sidegraph-verify --against <git-ref>`` on the command line instead; that layer
2481
+ needs git plumbing this MCP surface deliberately doesn't carry.
2482
+
2483
+ Returns ``{"clean": bool, "violations": [{"code", "path", "detail"}, ...]}`` —
2484
+ ``violations`` is empty iff ``clean`` is ``True``.
2485
+ """
2486
+ return _verify_store_impl(_get_store())
2487
+
2488
+
2489
+ def _anchor_leaf_summary(store: Store, name: str, file_path: str | None) -> dict | None:
2490
+ """``{"entity_id", "canonical_name", "tier": 2}`` for the Tier-2 leaf entity an anchor
2491
+ was just resolved/orphan-bound to — looked up post-write via ``resolve_descriptor`` (the
2492
+ same identity rule both ``resolve_and_bind`` and ``_bind_orphaned`` key their entity
2493
+ lookup on, INCLUDING path-less adoption), matching ``_entity_summaries``'s per-binding
2494
+ shape (``add_decision``/``add_fact``'s own vocabulary). ``None`` only if the entity
2495
+ somehow isn't findable right after being upserted (defensive; not expected in practice).
2496
+
2497
+ Plain ``find_entity`` here would report ``None`` for every path-less anchor the write
2498
+ just adopted onto a carrier — the read/write split this whole change exists to close."""
2499
+ entity = store.resolve_descriptor(name, file_path)
2500
+ if entity is None:
2501
+ return None
2502
+ return {"entity_id": entity.entity_id, "canonical_name": entity.canonical_name, "tier": 2}
2503
+
2504
+
2505
+ def _add_anchors_impl(
2506
+ store: Store,
2507
+ reader,
2508
+ record_id: str,
2509
+ anchors: list[dict],
2510
+ ) -> dict:
2511
+ """Testable core for add_anchors (design/superpowers/specs/
2512
+ 2026-07-11-ci-integrity-design.md ruling 3): append bindings to an EXISTING decision
2513
+ or fact — generalizes ``_bind_fact_anchors``'s resolve-or-orphan ladder (never the
2514
+ silent no-op ``_resolve_anchors`` gives a no-reader ``add_decision`` call) to either
2515
+ record kind.
2516
+
2517
+ Routing: ``store.get_decision(record_id)``, else ``store.get_fact(record_id)``, else
2518
+ an error dict — never a raised exception, never a guess at which kind an id belongs
2519
+ to. Relations are validated up front via ``_validate_anchor_relations``, before any
2520
+ binding is written — the same atomic-batch guarantee ``add_decision``/``add_fact``
2521
+ give: either every anchor in the call is legal and all of them bind, or nothing does.
2522
+
2523
+ This is a BINDINGS-ONLY write: only ``bindings/<record_id>.json`` (and any newly
2524
+ minted ``entities/<id>.json``) changes — the decision/fact's own record file is never
2525
+ touched, so this stays legal under verify's transition rules (a record's content
2526
+ fields are otherwise immutable outside real status/``valid_to`` transitions).
2527
+
2528
+ Per anchor: resolved against the graph via ``resolve_and_bind`` when ``reader`` is
2529
+ present (same ladder every other anchoring tool here uses) — an ambiguous name is
2530
+ reported, never guessed, and creates no Tier-2 leaf; a resolved name lands a live
2531
+ Tier-2 leaf (+ Tier-1 domain/community). With no reader, or a name that resolves to
2532
+ nothing, the anchor still binds — an orphaned Tier-2 leaf via ``_bind_orphaned`` — so
2533
+ a re-anchor request is never silently dropped for lack of a graph.
2534
+ """
2535
+ if store.get_decision(record_id) is None and store.get_fact(record_id) is None:
2536
+ return {"error": f"unknown record {record_id!r}"}
2537
+ _validate_anchor_relations(anchors)
2538
+
2539
+ bound: list[dict] = []
2540
+ orphaned: list[dict] = []
2541
+ ambiguous: list[dict] = []
2542
+ for raw in anchors or []:
2543
+ name = raw.get("name")
2544
+ if not name:
2545
+ continue
2546
+ file_path = raw.get("file_path")
2547
+ relation = raw.get("relation")
2548
+ ref = Descriptor(name=name, file_path=file_path)
2549
+ if reader is not None:
2550
+ result = resolve_and_bind(record_id, ref, reader, store, relation=relation)
2551
+ if result.status == "ambiguous":
2552
+ ambiguous.append(
2553
+ {"name": name, "reason": "ambiguous", "candidates": result.candidates[:5]}
2554
+ )
2555
+ continue
2556
+ summary = _anchor_leaf_summary(store, name, file_path)
2557
+ if summary is not None:
2558
+ (bound if result.status == "resolved" else orphaned).append(summary)
2559
+ else:
2560
+ _bind_orphaned(record_id, ref, store, relation=relation)
2561
+ summary = _anchor_leaf_summary(store, name, file_path)
2562
+ if summary is not None:
2563
+ orphaned.append(summary)
2564
+
2565
+ return {"record_id": record_id, "bound": bound, "orphaned": orphaned, "ambiguous": ambiguous}
2566
+
2567
+
2568
+ @mcp.tool
2569
+ def add_anchors(record_id: str, anchors: list[dict]) -> dict:
2570
+ """Append bindings to an EXISTING decision or fact — in-place re-anchoring for the
2571
+ triage flow (design/superpowers/specs/2026-07-11-ci-integrity-design.md ruling 3).
2572
+
2573
+ Use this when triage (after ``sync_anchors``) finds "code moved, decision still
2574
+ valid": it heals an orphaned/stale anchor in place instead of forcing a
2575
+ content-free ``supersede_decision``/``supersede_fact`` — which would pollute history
2576
+ with a successor that says nothing new. Reach for supersede instead when the CONTENT
2577
+ actually changed (the choice/rejected/consequences text), not just where the code
2578
+ that decision is about now lives.
2579
+
2580
+ ``anchors`` is the same ``{"name", "file_path"?, "relation"?}`` ref shape every other
2581
+ anchoring tool here takes. This is BINDINGS-ONLY: the decision/fact's own record file
2582
+ is never rewritten — only its bindings (and any newly minted entity) — so append-only
2583
+ history and verify's transition rules stay intact.
2584
+
2585
+ Routing tries ``record_id`` as a decision, then as a fact; an id that resolves to
2586
+ neither writes nothing and returns ``{"error": "unknown record '<id>'"}`` (never a
2587
+ guess). Relations are validated before anything is written — an invalid ``relation``
2588
+ raises, same as ``add_decision``/``add_fact``.
2589
+
2590
+ Returns ``{"record_id", "bound": [...], "orphaned": [...], "ambiguous": [...]}`` —
2591
+ ``bound``/``orphaned`` entries are ``{"entity_id", "canonical_name", "tier": 2}``
2592
+ entity summaries (same shape ``add_decision``/``add_fact`` return per binding):
2593
+ ``bound`` for anchors that resolved to exactly one live graph node, ``orphaned`` for
2594
+ anchors bound with no graph present or that resolved to nothing (never dropped either
2595
+ way). ``ambiguous`` is ``{"name", "reason": "ambiguous", "candidates"}`` (capped at 5)
2596
+ for anchor names that matched more than one graph node — no leaf created, the same
2597
+ per-anchor feedback ``add_decision``'s ``anchors_skipped`` gives.
2598
+ """
2599
+ return _add_anchors_impl(_get_store(), _load_reader(), record_id, anchors)
2600
+
2601
+
2602
+ def main() -> None:
2603
+ """Console-script entry point (``sidegraph-mcp``)."""
2604
+ mcp.run()
2605
+
2606
+
2607
+ if __name__ == "__main__":
2608
+ main()