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/__init__.py +37 -0
- sidegraph/anchoring.py +246 -0
- sidegraph/bootstrap/__init__.py +49 -0
- sidegraph/bootstrap/apply.py +603 -0
- sidegraph/bootstrap/catalog.py +92 -0
- sidegraph/bootstrap/cli.py +827 -0
- sidegraph/bootstrap/integrations.py +184 -0
- sidegraph/bootstrap/model.py +277 -0
- sidegraph/bootstrap/planner.py +400 -0
- sidegraph/bootstrap/proof.py +103 -0
- sidegraph/bootstrap/review.py +331 -0
- sidegraph/bootstrap/scan.py +289 -0
- sidegraph/capture.py +1794 -0
- sidegraph/cli.py +1902 -0
- sidegraph/config.py +148 -0
- sidegraph/doc_import.py +2099 -0
- sidegraph/doctor.py +1429 -0
- sidegraph/domains.py +902 -0
- sidegraph/engine/__init__.py +7 -0
- sidegraph/engine/reader.py +353 -0
- sidegraph/gitio.py +572 -0
- sidegraph/host/__init__.py +7 -0
- sidegraph/host/hooks.py +770 -0
- sidegraph/importer.py +239 -0
- sidegraph/okf.py +471 -0
- sidegraph/profiles.py +459 -0
- sidegraph/retrieval.py +1657 -0
- sidegraph/schema.py +386 -0
- sidegraph/server.py +2608 -0
- sidegraph/store.py +3363 -0
- sidegraph/sync.py +885 -0
- sidegraph/verify.py +1040 -0
- sidegraph/viz/__init__.py +4 -0
- sidegraph/viz/assets/vis-network.min.js +33 -0
- sidegraph/viz/model.py +248 -0
- sidegraph/viz/render.py +110 -0
- sidegraph/viz/template.html +131 -0
- sidegraph-0.1.0.dist-info/METADATA +392 -0
- sidegraph-0.1.0.dist-info/RECORD +42 -0
- sidegraph-0.1.0.dist-info/WHEEL +4 -0
- sidegraph-0.1.0.dist-info/entry_points.txt +18 -0
- sidegraph-0.1.0.dist-info/licenses/LICENSE +201 -0
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()
|