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/cli.py
ADDED
|
@@ -0,0 +1,1902 @@
|
|
|
1
|
+
"""CLI entry points: init (Stage 2 bootstrap), ratify (Stage 5), sync (Stage 6),
|
|
2
|
+
import (semantic-docs-layer wave, S2), domains (mind-model layer, M3), compact (git-native
|
|
3
|
+
store wave, N5), verify (CI-integrity wave, snapshot layer).
|
|
4
|
+
|
|
5
|
+
``sidegraph-init`` bootstraps ``.sidegraph/`` in a target repo and prints ready-to-paste
|
|
6
|
+
wiring. ``sidegraph-ratify`` lists proposed decisions AND facts human-readably (a
|
|
7
|
+
decision's still-proposed supporting facts nest under it; standalone facts get their own
|
|
8
|
+
section); ``--accept``/``--drop``/``--all`` apply the gestures to decision, fact, or domain
|
|
9
|
+
ids alike. Non-interactive flags keep it scriptable; drop is append-only (sets valid_to +
|
|
10
|
+
rejected, never deletes). ``sidegraph-sync`` re-anchors the store after a
|
|
11
|
+
Graphify rebuild; ``--json`` prints the report as one JSON object (``sync.report_as_dict``
|
|
12
|
+
-- the same shape the ``sync_anchors`` MCP tool returns) and ``--check`` exits 2 when
|
|
13
|
+
``sync.report_has_findings`` flags an error outcome, a stale decision, or a slug conflict
|
|
14
|
+
-- orphaned/ambiguous outcomes stay informational, a live-experiment refinement (design/
|
|
15
|
+
superpowers/specs/2026-07-11-ci-live-findings-design.md ruling 1: a legitimate rename+heal
|
|
16
|
+
must not red-flag forever). ``sidegraph-import`` bootstraps decisions from rationale nodes
|
|
17
|
+
(code AST + LLM docs) via ``importer.import_rationales``, or (with ``--docs``) from decision-shaped
|
|
18
|
+
markdown via ``doc_import.import_docs``. ``sidegraph-domains`` authors Domain proposals:
|
|
19
|
+
``bootstrap`` (from graph communities, via ``domains.bootstrap_domains``) and ``add`` (manual,
|
|
20
|
+
mirrors the ``add_domain`` MCP tool). ``sidegraph-compact`` packs terminal-status decisions/
|
|
21
|
+
domains into an immutable ``archive/`` segment via ``Store.compact`` — explicit, human-run
|
|
22
|
+
maintenance; never called by sync/retrieval/ratify. ``sidegraph-verify`` lints the store's
|
|
23
|
+
canonical files against the write-path invariants ``store.py`` enforces at write time
|
|
24
|
+
(``verify.verify_snapshot`` — pure read, no ``Store()``; see design/superpowers/specs/
|
|
25
|
+
2026-07-11-ci-integrity-design.md ruling 2); ``--json`` prints ``{"clean", "violations"}``,
|
|
26
|
+
exit 0 clean / 1 operational error (unreadable store dir) / 2 violations found. ``--against
|
|
27
|
+
<git-ref>`` additionally runs the transition layer (``verify.verify_against`` — git plumbing
|
|
28
|
+
via subprocess, classifying every store file changed vs ``<git-ref>`` against the store's OWN
|
|
29
|
+
write rules; the always-on snapshot layer still runs too, so violations from both layers are
|
|
30
|
+
reported together under the same exit contract) — an unresolvable ref or a store dir outside
|
|
31
|
+
any git repo is also an operational error (exit 1), never a violation. All return
|
|
32
|
+
non-zero on hard failures so CI can script them — see docs/guides/capturing-decisions.md
|
|
33
|
+
(ratify), docs/reference/cli.md#sidegraph-import (import),
|
|
34
|
+
docs/reference/cli.md#importing-decision-shaped-markdown---docs (--docs),
|
|
35
|
+
docs/concepts/mind-model.md (domains),
|
|
36
|
+
docs/reference/cli.md#sidegraph-compact (compact), and
|
|
37
|
+
docs/reference/cli.md#sidegraph-verify (verify).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import sys
|
|
46
|
+
import webbrowser
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
from pydantic import ValidationError
|
|
50
|
+
|
|
51
|
+
from . import gitio
|
|
52
|
+
from .capture import (
|
|
53
|
+
RatifyPolicy,
|
|
54
|
+
format_domain_proposal,
|
|
55
|
+
format_fact_proposal,
|
|
56
|
+
format_proposal,
|
|
57
|
+
parse_ratify_policy,
|
|
58
|
+
)
|
|
59
|
+
from .config import DEFAULT_STORE_DIR, resolve_store_path
|
|
60
|
+
from .doc_import import _MIN_SECTION_LIMIT, _SECTION_LIMIT, import_docs
|
|
61
|
+
from .doctor import curate
|
|
62
|
+
from .domains import DEFAULT_CANDIDATE_LIMIT, bootstrap_domains, collect_domain_candidates
|
|
63
|
+
from .engine.reader import GraphifyReader
|
|
64
|
+
from .importer import import_rationales
|
|
65
|
+
from .okf import build_bundle, write_bundle
|
|
66
|
+
from .profiles import PROFILES, get_profile
|
|
67
|
+
from .retrieval import TOC_CACHE_KEY, build_toc, proposal_surfaces
|
|
68
|
+
from .schema import Decision, DecisionKind, DecisionStatus, Domain, DomainStatus, Fact, Provenance
|
|
69
|
+
from .store import VOLATILE_STALE_KEY, Store
|
|
70
|
+
from .sync import (
|
|
71
|
+
activate_accepted_domain,
|
|
72
|
+
report_as_dict,
|
|
73
|
+
report_has_findings,
|
|
74
|
+
sync,
|
|
75
|
+
)
|
|
76
|
+
from .verify import verify_against, verify_snapshot
|
|
77
|
+
from .viz.model import build_graph
|
|
78
|
+
from .viz.render import to_html, to_json
|
|
79
|
+
|
|
80
|
+
# Above this count, a non-dry `sidegraph-domains bootstrap` run nags on stderr to
|
|
81
|
+
# reconsider --min-members/--limit rather than blindly ratifying everything — a big
|
|
82
|
+
# unfiltered batch is exactly the case where selective ratification matters most.
|
|
83
|
+
_BOOTSTRAP_LARGE_RUN_THRESHOLD = 50
|
|
84
|
+
|
|
85
|
+
# Shared ``--db`` help text (design §5): every subcommand below resolves the same way —
|
|
86
|
+
# through ``config.resolve_store_path`` — so they all document the same precedence.
|
|
87
|
+
# ``SIDEGRAPH_DIR`` is the primary knob; ``SIDEGRAPH_DB`` is kept for back-compat (a
|
|
88
|
+
# one-line deprecation notice prints to stderr the first time it's actually used).
|
|
89
|
+
_DB_HELP = (
|
|
90
|
+
"store directory (default: $SIDEGRAPH_DIR if set, else existing "
|
|
91
|
+
f"'{DEFAULT_STORE_DIR}/' if present, else '{DEFAULT_STORE_DIR}'; $SIDEGRAPH_DB is "
|
|
92
|
+
"honored for back-compat, deprecated — a legacy *.db file path still works there via "
|
|
93
|
+
"one-time migration)"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _looks_already_initialized(path: Path) -> bool:
|
|
98
|
+
"""True iff ``path`` already names a real store: an existing legacy single-file store
|
|
99
|
+
(a plain file — ``Store`` migrates it in place on open), a directory that already
|
|
100
|
+
carries the canonical layout (its ``format`` marker exists — see
|
|
101
|
+
``store.Store._ensure_format_marker``), or a directory holding an UN-migrated legacy
|
|
102
|
+
``decisions.db`` (no ``format`` marker yet, since migration hasn't run — see
|
|
103
|
+
``store.Store._migrate_legacy``). That last case (review Minor 5) is real,
|
|
104
|
+
pre-existing data: without it, ``init_main`` reported "created store" for a directory
|
|
105
|
+
``Store(...)`` was about to MIGRATE a moment later, understating what actually
|
|
106
|
+
happened. A bare ``path.exists()`` used to false-positive on any pre-existing-but-empty
|
|
107
|
+
directory too, reporting "already initialized" for a directory ``sidegraph-init`` was
|
|
108
|
+
about to populate for the first time."""
|
|
109
|
+
if path.is_file():
|
|
110
|
+
return True
|
|
111
|
+
if (path / "format").exists():
|
|
112
|
+
return True
|
|
113
|
+
return (path / "decisions.db").is_file()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def init_main(argv: list[str] | None = None) -> int:
|
|
117
|
+
"""Bootstrap a target repo: create the store, check for the graph, print wiring.
|
|
118
|
+
|
|
119
|
+
Idempotent: safe to re-run on an already-initialized repo (says so, exits 0). The graph
|
|
120
|
+
is optional at init time — a missing ``graph.json`` is reported, not an error, since
|
|
121
|
+
``sidegraph-init`` typically runs before the first ``graphify update .``.
|
|
122
|
+
"""
|
|
123
|
+
parser = argparse.ArgumentParser(
|
|
124
|
+
prog="sidegraph-init",
|
|
125
|
+
description="Bootstrap Sidegraph in a repo: create the store and print wiring snippets.",
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"--db",
|
|
129
|
+
default=None,
|
|
130
|
+
help="store directory to create (default: $SIDEGRAPH_DIR if set, else existing "
|
|
131
|
+
f"'{DEFAULT_STORE_DIR}/' if present, else '{DEFAULT_STORE_DIR}' — the recommended "
|
|
132
|
+
"in-repo location; $SIDEGRAPH_DB is honored for back-compat, deprecated. Passing a "
|
|
133
|
+
"legacy *.db file path still works via one-time migration)",
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--graph",
|
|
137
|
+
default=os.environ.get("SIDEGRAPH_GRAPH", "graphify-out/graph.json"),
|
|
138
|
+
help="graphify-out/graph.json path to check for (never created here; read-only input)",
|
|
139
|
+
)
|
|
140
|
+
args = parser.parse_args(argv)
|
|
141
|
+
# Resolved AFTER parse_args, and only when --db was actually omitted (review Minor 4):
|
|
142
|
+
# resolve_store_path() can print SIDEGRAPH_DB's one-line deprecation notice as a side
|
|
143
|
+
# effect, which must never fire just from BUILDING the parser -- e.g. on `--help`, or
|
|
144
|
+
# on a run that passes --db explicitly and was never going to consult the env at all.
|
|
145
|
+
if args.db is None:
|
|
146
|
+
args.db = resolve_store_path(warn_on_create=False)
|
|
147
|
+
|
|
148
|
+
db_path = Path(args.db)
|
|
149
|
+
already_initialized = _looks_already_initialized(db_path)
|
|
150
|
+
try:
|
|
151
|
+
Store(args.db) # creates parent dir(s) + schema-stamped store; idempotent itself
|
|
152
|
+
except Exception as e:
|
|
153
|
+
print(f"store not writable ({db_path}): {e}")
|
|
154
|
+
return 1
|
|
155
|
+
|
|
156
|
+
if already_initialized:
|
|
157
|
+
print(f"already initialized: {db_path} exists.")
|
|
158
|
+
else:
|
|
159
|
+
print(f"created store: {db_path}")
|
|
160
|
+
|
|
161
|
+
graph_path = Path(args.graph)
|
|
162
|
+
if graph_path.exists():
|
|
163
|
+
print(f"found graph: {graph_path}")
|
|
164
|
+
else:
|
|
165
|
+
print(
|
|
166
|
+
f"missing graph: {graph_path} — run `graphify update .`, then `sidegraph-sync`, "
|
|
167
|
+
"to anchor decisions to code (optional; Sidegraph works without it)."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
print()
|
|
171
|
+
print("Wire up Claude Code — the plugin installs the MCP server and all three hooks")
|
|
172
|
+
print("(SessionStart, Stop, PreToolUse) automatically:")
|
|
173
|
+
print()
|
|
174
|
+
print(" /plugin marketplace add SantyagoSeaman/sidegraph")
|
|
175
|
+
print(" /plugin install sidegraph@sidegraph")
|
|
176
|
+
print()
|
|
177
|
+
print("Prefer no plugin? Register just the MCP server (writes a repo-committed")
|
|
178
|
+
print(".mcp.json, so teammates get it via git):")
|
|
179
|
+
print()
|
|
180
|
+
print(
|
|
181
|
+
" claude mcp add sidegraph -s project "
|
|
182
|
+
"--env SIDEGRAPH_DIR=.sidegraph --env SIDEGRAPH_GRAPH=graphify-out/graph.json "
|
|
183
|
+
"-- uvx --from git+https://github.com/SantyagoSeaman/sidegraph.git@main "
|
|
184
|
+
"sidegraph-mcp"
|
|
185
|
+
)
|
|
186
|
+
print()
|
|
187
|
+
print("Hook-by-hook manual setup and Codex CLI wiring: see")
|
|
188
|
+
print("docs/getting-started/claude-code-setup.md and docs/getting-started/codex-setup.md.")
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _route_ratify(store: Store, id_: str, action: str) -> tuple[str, list[Fact]]:
|
|
193
|
+
"""Route one id to a decision, a fact, or a domain by lookup (decision first, then
|
|
194
|
+
fact, then domain) and apply ``action`` ("accept" | "drop"); unknown ids get a generic
|
|
195
|
+
error. Mirrors ``server._ratify_one``'s routing — including its ``(result, cascaded)``
|
|
196
|
+
return shape (``cascaded`` is the list of facts that rode a DECISION's verdict in this
|
|
197
|
+
call; always empty for a fact/domain id) — so ``sidegraph-ratify`` covers all three
|
|
198
|
+
kinds without importing ``server.py`` — that used to matter more than it does now:
|
|
199
|
+
server.py's old module-level ``_store = Store(...)`` created a stray store as a side
|
|
200
|
+
effect of merely importing it, but ``server._get_store()`` is a lazy, memoized accessor
|
|
201
|
+
now (see ``config.py``), so that particular hazard is gone. This CLI still avoids the
|
|
202
|
+
import to stay clear of server.py's FastMCP app entirely — a much heavier dependency
|
|
203
|
+
than a script needs — so the seam is kept even though the original hazard no longer
|
|
204
|
+
forces it.
|
|
205
|
+
"""
|
|
206
|
+
if store.get_decision(id_) is not None:
|
|
207
|
+
try:
|
|
208
|
+
if action == "accept":
|
|
209
|
+
_decision, cascaded = store.ratify(id_)
|
|
210
|
+
return "accepted", cascaded
|
|
211
|
+
_decision, cascaded = store.drop(id_)
|
|
212
|
+
return "dropped", cascaded
|
|
213
|
+
except ValueError as e:
|
|
214
|
+
return f"error: {e}", []
|
|
215
|
+
if store.get_fact(id_) is not None:
|
|
216
|
+
try:
|
|
217
|
+
if action == "accept":
|
|
218
|
+
store.ratify_fact(id_)
|
|
219
|
+
else:
|
|
220
|
+
store.drop_fact(id_)
|
|
221
|
+
return ("accepted" if action == "accept" else "dropped"), []
|
|
222
|
+
except ValueError as e:
|
|
223
|
+
return f"error: {e}", []
|
|
224
|
+
if store.get_domain(id_) is not None:
|
|
225
|
+
result = (
|
|
226
|
+
store.ratify_domains(accept=[id_])
|
|
227
|
+
if action == "accept"
|
|
228
|
+
else store.ratify_domains(drop=[id_])
|
|
229
|
+
)
|
|
230
|
+
return result[id_], []
|
|
231
|
+
return f"error: unknown id {id_!r} (not a pending decision, fact, or domain)", []
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _nested_fact_ids(store: Store, proposals: list[Decision]) -> set[str]:
|
|
235
|
+
"""Ids of still-``PROPOSED`` facts that support one of ``proposals`` -- these ride
|
|
236
|
+
their decision's ratify verdict (cascade) and are rendered nested under that decision
|
|
237
|
+
in the queue, never listed again in the standalone "Facts:" section. Mirrors
|
|
238
|
+
``server._nested_fact_ids`` (duplicated rather than imported, same rationale as
|
|
239
|
+
``_route_ratify``'s docstring) — shared here by the bare-run render and ``--all``'s id
|
|
240
|
+
collection so neither double-counts a cascaded fact."""
|
|
241
|
+
return {
|
|
242
|
+
f.id
|
|
243
|
+
for d in proposals
|
|
244
|
+
for f in store.facts_for_decision(d.id)
|
|
245
|
+
if f.status == DecisionStatus.PROPOSED
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
_NOT_SURFACING = "[not surfacing]"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _format_proposed_decision_block(store: Store, d: Decision) -> str:
|
|
253
|
+
"""``format_proposal(d)`` plus one indented `` evidence: ...`` line per still-
|
|
254
|
+
``PROPOSED`` fact supporting it -- mirrors ``server._format_proposed_decision_block``
|
|
255
|
+
(duplicated, same rationale as ``_route_ratify``'s docstring) so the bare-run render
|
|
256
|
+
stays pixel-identical to ``list_proposed``'s."""
|
|
257
|
+
# Round-2 practitioner review: mark what has already stopped being delivered. The
|
|
258
|
+
# surfacing window is only humane if the queue says which items it has stopped
|
|
259
|
+
# serving — otherwise a reviewer cannot tell an urgent backlog from an inert one, and
|
|
260
|
+
# the window reads as a silent drop. The record stays listed and ratifiable either way.
|
|
261
|
+
head = format_proposal(d)
|
|
262
|
+
if not proposal_surfaces(d):
|
|
263
|
+
# On the TITLE line, where the eye lands — not at the end of a multi-line block.
|
|
264
|
+
first, _, rest = head.partition("\n")
|
|
265
|
+
head = f"{first} {_NOT_SURFACING}" + (f"\n{rest}" if rest else "")
|
|
266
|
+
lines = [head]
|
|
267
|
+
for f in store.facts_for_decision(d.id):
|
|
268
|
+
if f.status == DecisionStatus.PROPOSED:
|
|
269
|
+
lines.append(f" evidence: {f.statement} [{f.source}] ({f.id})")
|
|
270
|
+
return "\n".join(lines)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _ratified_domain(store: Store, id_: str, result: str) -> bool:
|
|
274
|
+
"""True iff ``id_`` was routed to (and actually landed on) a domain — gates the TOC
|
|
275
|
+
cache refresh below to real domain changes only (mirrors ``server._ratified_domain``;
|
|
276
|
+
duplicated rather than imported so this CLI stays clear of ``server.py``'s module-level
|
|
277
|
+
store, same rationale as ``_route_ratify``'s docstring)."""
|
|
278
|
+
if result.startswith("error"):
|
|
279
|
+
return False
|
|
280
|
+
return store.get_decision(id_) is None and store.get_domain(id_) is not None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _ratify_reader(graph_path: str, db_path: str | Path):
|
|
284
|
+
"""A best-effort reader for ratify's immediate membership resolve (CLI/MCP parity,
|
|
285
|
+
v0.2-scope). ``None`` when there is no readable graph — ratify must still work in a
|
|
286
|
+
checkout that has never been built, exactly as it did before this existed, so every
|
|
287
|
+
failure here degrades to the pre-existing "schedule the heal" branch rather than
|
|
288
|
+
failing the accept.
|
|
289
|
+
|
|
290
|
+
A RELATIVE ``--graph`` is resolved against the STORE's own project root, not the
|
|
291
|
+
process CWD. Ratifying a store in another directory would otherwise pick up whatever
|
|
292
|
+
``graphify-out/graph.json`` happened to sit beside the shell — resolving one project's
|
|
293
|
+
domain membership against a different project's graph. Pinned by
|
|
294
|
+
``test_cli_ratify_ignores_a_graph_belonging_to_another_project``, which builds its own
|
|
295
|
+
graph in a foreign directory and chdirs there — the pre-existing
|
|
296
|
+
``test_cli_ratify_of_an_accepted_domain_schedules_a_heal`` also catches it, but only
|
|
297
|
+
when a gitignored ``graphify-out/graph.json`` happens to exist in the checkout, so it
|
|
298
|
+
is not a guard CI can rely on (review finding 5)."""
|
|
299
|
+
path = Path(graph_path)
|
|
300
|
+
if not path.is_absolute():
|
|
301
|
+
# The project root is the store path's PARENT. Only a legacy single-FILE store
|
|
302
|
+
# living inside a store directory needs one more level up. A name check on
|
|
303
|
+
# ".sidegraph" was tried and rejected (review R2-4): `--db mystore` is a supported
|
|
304
|
+
# invocation, and a name check resolved such a store's root to itself, silently
|
|
305
|
+
# disabling this for anyone not on the default directory name — the exact "dead
|
|
306
|
+
# while looking alive" failure this whole helper exists to avoid.
|
|
307
|
+
store = Path(db_path).resolve()
|
|
308
|
+
root = store.parent
|
|
309
|
+
if not store.is_dir() and (root.name == ".sidegraph" or (root / "format").is_file()):
|
|
310
|
+
root = root.parent
|
|
311
|
+
path = root / path
|
|
312
|
+
if not path.is_file():
|
|
313
|
+
return None
|
|
314
|
+
try:
|
|
315
|
+
return GraphifyReader(str(path))
|
|
316
|
+
except Exception:
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def ratify_main(argv: list[str] | None = None) -> int:
|
|
321
|
+
parser = argparse.ArgumentParser(
|
|
322
|
+
prog="sidegraph-ratify",
|
|
323
|
+
description="Review and ratify Sidegraph decisions and domains awaiting acceptance.",
|
|
324
|
+
)
|
|
325
|
+
parser.add_argument(
|
|
326
|
+
"--db",
|
|
327
|
+
default=None,
|
|
328
|
+
help=_DB_HELP,
|
|
329
|
+
)
|
|
330
|
+
parser.add_argument("--accept", nargs="*", default=[], metavar="ID")
|
|
331
|
+
parser.add_argument("--drop", nargs="*", default=[], metavar="ID")
|
|
332
|
+
parser.add_argument(
|
|
333
|
+
"--all", action="store_true", help="accept every pending proposal (decisions AND domains)"
|
|
334
|
+
)
|
|
335
|
+
parser.add_argument(
|
|
336
|
+
"--graph",
|
|
337
|
+
default=os.environ.get("SIDEGRAPH_GRAPH", "graphify-out/graph.json"),
|
|
338
|
+
help=(
|
|
339
|
+
"graph to resolve an accepted domain's membership against, immediately, the "
|
|
340
|
+
"way the MCP ratify tool does (CLI/MCP parity). Read-only input, same meaning "
|
|
341
|
+
"as every other subcommand's --graph. A relative path resolves against the "
|
|
342
|
+
"STORE's project root, not the shell's CWD. Missing or unreadable is fine: the "
|
|
343
|
+
"accept still lands and the membership heal is scheduled for the next sync."
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
args = parser.parse_args(argv)
|
|
347
|
+
args.db = resolve_store_path(args.db)
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
store = Store(args.db)
|
|
351
|
+
except Exception as e:
|
|
352
|
+
print(f"store not readable ({args.db}): {e}")
|
|
353
|
+
return 1
|
|
354
|
+
|
|
355
|
+
if args.all:
|
|
356
|
+
proposals = list(store.iter_proposed())
|
|
357
|
+
args.accept = [d.id for d in proposals]
|
|
358
|
+
args.accept += [d.domain_id for d in store.iter_domains(status=DomainStatus.PROPOSED)]
|
|
359
|
+
# Standalone proposed facts only -- a fact with a proposed supporter rides that
|
|
360
|
+
# decision's cascade already; adding it here too would double-report it.
|
|
361
|
+
nested = _nested_fact_ids(store, proposals)
|
|
362
|
+
args.accept += [f.id for f in store.iter_proposed_facts() if f.id not in nested]
|
|
363
|
+
|
|
364
|
+
if not args.accept and not args.drop:
|
|
365
|
+
proposals = list(store.iter_proposed())
|
|
366
|
+
domains = list(store.iter_domains(status=DomainStatus.PROPOSED))
|
|
367
|
+
nested = _nested_fact_ids(store, proposals)
|
|
368
|
+
standalone_facts = [f for f in store.iter_proposed_facts() if f.id not in nested]
|
|
369
|
+
if not proposals and not domains and not standalone_facts:
|
|
370
|
+
print("No proposed decisions, facts, or domains pending ratification.")
|
|
371
|
+
else:
|
|
372
|
+
printed = False
|
|
373
|
+
if proposals:
|
|
374
|
+
print("Decisions:")
|
|
375
|
+
print("\n\n".join(_format_proposed_decision_block(store, d) for d in proposals))
|
|
376
|
+
printed = True
|
|
377
|
+
if standalone_facts:
|
|
378
|
+
if printed:
|
|
379
|
+
print()
|
|
380
|
+
print("Facts:")
|
|
381
|
+
print("\n\n".join(format_fact_proposal(f) for f in standalone_facts))
|
|
382
|
+
printed = True
|
|
383
|
+
if domains:
|
|
384
|
+
if printed:
|
|
385
|
+
print()
|
|
386
|
+
print("Domains:")
|
|
387
|
+
print("\n\n".join(format_domain_proposal(d) for d in domains))
|
|
388
|
+
printed = True
|
|
389
|
+
print(
|
|
390
|
+
f"\n{len(proposals) + len(domains) + len(standalone_facts)} pending. "
|
|
391
|
+
"Use --accept ID... / --drop ID... / --all."
|
|
392
|
+
)
|
|
393
|
+
return 0
|
|
394
|
+
|
|
395
|
+
had_error = False
|
|
396
|
+
domain_changed = False
|
|
397
|
+
domain_accepted = False # accept-side only -- drops need no membership resolution
|
|
398
|
+
accepted_domain_ids: list[str] = [] # the ones whose membership must resolve now
|
|
399
|
+
accepted: dict[str, str] = {}
|
|
400
|
+
|
|
401
|
+
def _process_accept(did: str) -> None:
|
|
402
|
+
# Task 7 fix pass, Important-1: shared by both accept passes below so a
|
|
403
|
+
# decision-id's own line and its cascade lines print together, in whichever pass
|
|
404
|
+
# actually routes it.
|
|
405
|
+
nonlocal domain_changed, domain_accepted, had_error
|
|
406
|
+
result, cascaded = _route_ratify(store, did, "accept")
|
|
407
|
+
accepted[did] = result
|
|
408
|
+
if _ratified_domain(store, did, result):
|
|
409
|
+
domain_changed = True
|
|
410
|
+
domain_accepted = True
|
|
411
|
+
accepted_domain_ids.append(did)
|
|
412
|
+
if result.startswith("error"):
|
|
413
|
+
detail = result.split(":", 1)[1].strip() if ":" in result else result
|
|
414
|
+
print(f"error {did}: {detail}")
|
|
415
|
+
had_error = True
|
|
416
|
+
else:
|
|
417
|
+
print(f"{result} {did}")
|
|
418
|
+
for f in cascaded:
|
|
419
|
+
accepted[f.id] = f"accepted (evidence of {did})"
|
|
420
|
+
print(f"accepted (evidence of {did}) {f.id}")
|
|
421
|
+
|
|
422
|
+
# Pass 1: every decision id in --accept first, regardless of its position in the list
|
|
423
|
+
# (mirrors server._ratify_impl's order-independence fix) -- a fact nested under one of
|
|
424
|
+
# these decisions must always be swept by ITS cascade, never independently re-ratified
|
|
425
|
+
# first just because it was listed earlier. Pass 2 then skips any id pass 1 already
|
|
426
|
+
# reported via cascade: no error line, no had_error, no re-processing a record that's
|
|
427
|
+
# already been flipped.
|
|
428
|
+
for did in args.accept:
|
|
429
|
+
if did in accepted or store.get_decision(did) is None:
|
|
430
|
+
continue
|
|
431
|
+
_process_accept(did)
|
|
432
|
+
for did in args.accept:
|
|
433
|
+
if did in accepted:
|
|
434
|
+
continue
|
|
435
|
+
_process_accept(did)
|
|
436
|
+
dropped: dict[str, str] = {}
|
|
437
|
+
# Fix pass, Important-2 (CLI-only -- server._ratify_impl's drop loop already had this
|
|
438
|
+
# guard from its accept/drop cross-list dedup): `--drop <d> <nested-f>` (f supports d)
|
|
439
|
+
# used to print the cascade line for f, then hit f's own turn and re-route it --
|
|
440
|
+
# `drop_fact` now raises "fact ... is not proposed" (f is already REJECTED), printing a
|
|
441
|
+
# spurious "error" line and forcing exit 1 despite full success. No two-pass reordering
|
|
442
|
+
# here (unlike the accept loop) -- drop ids are dropped in the caller's own list order;
|
|
443
|
+
# this guard only skips an id ALREADY reported earlier in that same order, via cascade.
|
|
444
|
+
for did in args.drop:
|
|
445
|
+
if did in dropped:
|
|
446
|
+
continue
|
|
447
|
+
result, cascaded = _route_ratify(store, did, "drop")
|
|
448
|
+
dropped[did] = result
|
|
449
|
+
domain_changed = domain_changed or _ratified_domain(store, did, result)
|
|
450
|
+
if result.startswith("error"):
|
|
451
|
+
detail = result.split(":", 1)[1].strip() if ":" in result else result
|
|
452
|
+
print(f"error {did}: {detail}")
|
|
453
|
+
had_error = True
|
|
454
|
+
else:
|
|
455
|
+
print(f"{result} {did}")
|
|
456
|
+
for f in cascaded:
|
|
457
|
+
dropped[f.id] = f"dropped (evidence of {did})"
|
|
458
|
+
print(f"dropped (evidence of {did}) {f.id}")
|
|
459
|
+
# Fix: lazy sync alone keeps last_synced_graph_version current without ever recomputing
|
|
460
|
+
# the TOC cache, so "bootstrap -> ratify -> SessionStart TOC comes alive" did nothing
|
|
461
|
+
# until the next real graph rebuild. Rebuild immediately whenever >= 1 domain id was
|
|
462
|
+
# actually accepted/dropped by this run; a decisions-only ratify leaves it untouched.
|
|
463
|
+
if domain_changed:
|
|
464
|
+
store.set_meta(TOC_CACHE_KEY, json.dumps(build_toc(store)))
|
|
465
|
+
if domain_accepted:
|
|
466
|
+
# CLI/MCP ratify parity (v0.2-scope): resolve membership NOW, the way the MCP
|
|
467
|
+
# `ratify` tool does, so the same accept through two doors leaves the same state —
|
|
468
|
+
# a domain accepted in the shell used to show `communities: []` until the next
|
|
469
|
+
# sync. sync.activate_accepted_domain (design D2 shared helper) resolves each
|
|
470
|
+
# domain individually and schedules the VOLATILE_STALE_KEY heal itself whenever it
|
|
471
|
+
# cannot (no reader, or the refresh raises) -- never fails this ratify either way.
|
|
472
|
+
# This loop just tracks whether EVERY domain resolved (a redundant but harmless
|
|
473
|
+
# belt-and-suspenders flag set below) and renders the "path rule too broad"
|
|
474
|
+
# sentence, which the helper deliberately leaves to its callers (a literal trigger
|
|
475
|
+
# phrase in the sidegraph:heal-anchors skill).
|
|
476
|
+
reader = _ratify_reader(args.graph, args.db)
|
|
477
|
+
all_resolved = bool(accepted_domain_ids)
|
|
478
|
+
for domain_id in accepted_domain_ids:
|
|
479
|
+
domain = store.get_domain(domain_id)
|
|
480
|
+
if domain is None:
|
|
481
|
+
all_resolved = False
|
|
482
|
+
continue
|
|
483
|
+
# One domain failing must not strand the others unresolved (review 6b: a
|
|
484
|
+
# `break` left later domains with communities: [] until the next sync, which
|
|
485
|
+
# is the very asymmetry this parity fix closes) -- the helper isolates each
|
|
486
|
+
# domain's own try/except, so this loop just keeps going regardless.
|
|
487
|
+
activation = activate_accepted_domain(domain, store, reader)
|
|
488
|
+
if not activation.resolved:
|
|
489
|
+
all_resolved = False
|
|
490
|
+
if activation.overbroad is not None:
|
|
491
|
+
# Same sentence the MCP path prints — "path rule too broad" is a literal
|
|
492
|
+
# trigger phrase in the sidegraph:heal-anchors skill, so dropping it costs
|
|
493
|
+
# the CLI user the routed heal (review 6a).
|
|
494
|
+
prefixes = ", ".join(repr(pfx) for pfx in domain.path_prefixes)
|
|
495
|
+
print(
|
|
496
|
+
f" path rule too broad: {prefixes} match "
|
|
497
|
+
f"{activation.overbroad['matched']}/{activation.overbroad['total']} "
|
|
498
|
+
"communities — not applied; seed_anchors, if any, still applied"
|
|
499
|
+
)
|
|
500
|
+
if not all_resolved:
|
|
501
|
+
store.set_meta(VOLATILE_STALE_KEY, "1")
|
|
502
|
+
return 1 if had_error else 0
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def sync_main(argv: list[str] | None = None) -> int:
|
|
506
|
+
"""Re-anchor the decision store against the current graph (post-commit or on demand).
|
|
507
|
+
|
|
508
|
+
``--json`` prints ``sync.report_as_dict(report)`` as one JSON object to stdout --
|
|
509
|
+
nothing else on stdout -- the same shape the ``sync_anchors`` MCP tool returns.
|
|
510
|
+
``--check`` exits 2 when ``sync.report_has_findings`` flags an attention finding (an
|
|
511
|
+
error outcome, a stale decision, a slug conflict, or a domain refresh failure); exit 0
|
|
512
|
+
is a clean report (``orphaned``/``ambiguous`` outcomes and ``empty_domains``/
|
|
513
|
+
``overbroad_domains`` stay informational, never fail the check on their own -- a
|
|
514
|
+
legitimate rename+heal leaves the renamed-away entity's leaf orphaned for good, no
|
|
515
|
+
retirement path in an append-only store, and that residue must not red-flag forever;
|
|
516
|
+
when an orphaned/ambiguous anchor actually costs reachability, the record goes stale
|
|
517
|
+
and ``stale_decisions`` already fires). ``--check`` implies ``force=True`` -- a check
|
|
518
|
+
that could be silently pre-empted by an earlier caller (SessionStart, a retrieval MCP
|
|
519
|
+
call) consuming the one-shot cold-reload heal and discarding its report would be worse
|
|
520
|
+
than one that always pays the rebind ladder's cost, so ``--check`` never reads a
|
|
521
|
+
version-skip as "clean" the way a plain sync legitimately can. The two flags compose
|
|
522
|
+
(``--json --check``); the operational-error exit-1 paths below are unchanged. See
|
|
523
|
+
design/superpowers/specs/2026-07-11-ci-live-findings-design.md ruling 1.
|
|
524
|
+
"""
|
|
525
|
+
parser = argparse.ArgumentParser(
|
|
526
|
+
prog="sidegraph-sync",
|
|
527
|
+
description="Re-resolve entity anchors after a Graphify rebuild.",
|
|
528
|
+
)
|
|
529
|
+
parser.add_argument(
|
|
530
|
+
"--db",
|
|
531
|
+
default=None,
|
|
532
|
+
help=_DB_HELP,
|
|
533
|
+
)
|
|
534
|
+
parser.add_argument(
|
|
535
|
+
"--graph", default=os.environ.get("SIDEGRAPH_GRAPH", "graphify-out/graph.json")
|
|
536
|
+
)
|
|
537
|
+
parser.add_argument("--force", action="store_true", help="rerun even if version matches")
|
|
538
|
+
parser.add_argument(
|
|
539
|
+
"--json",
|
|
540
|
+
action="store_true",
|
|
541
|
+
help="print the report as one JSON object to stdout (nothing else) — same shape "
|
|
542
|
+
"as the sync_anchors MCP tool",
|
|
543
|
+
)
|
|
544
|
+
parser.add_argument(
|
|
545
|
+
"--check",
|
|
546
|
+
action="store_true",
|
|
547
|
+
help="exit 2 when the report has an attention finding (error outcome, stale "
|
|
548
|
+
"decision, or slug conflict — orphaned/ambiguous outcomes stay informational) "
|
|
549
|
+
"— for CI",
|
|
550
|
+
)
|
|
551
|
+
args = parser.parse_args(argv)
|
|
552
|
+
args.db = resolve_store_path(args.db)
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
reader = GraphifyReader(args.graph)
|
|
556
|
+
except Exception as e:
|
|
557
|
+
print(f"graph not readable ({args.graph}): {e}")
|
|
558
|
+
return 1
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
store = Store(args.db)
|
|
562
|
+
except Exception as e:
|
|
563
|
+
print(f"store not readable ({args.db}): {e}")
|
|
564
|
+
return 1
|
|
565
|
+
|
|
566
|
+
try:
|
|
567
|
+
# --check implies force=True: a check that can be silently pre-empted by an
|
|
568
|
+
# earlier caller (SessionStart, a retrieval MCP call) consuming the one-shot
|
|
569
|
+
# cold-reload heal and discarding its report is worse than one that always pays
|
|
570
|
+
# the rebind ladder's cost. See docs/reference/cli.md's --check section.
|
|
571
|
+
report = sync(store, reader, force=args.force or args.check)
|
|
572
|
+
except Exception as e:
|
|
573
|
+
print(f"sync failed: {e}")
|
|
574
|
+
return 1
|
|
575
|
+
|
|
576
|
+
report_dict = report_as_dict(report)
|
|
577
|
+
|
|
578
|
+
if args.json:
|
|
579
|
+
# Pure JSON on stdout — nothing else — so a CI step can pipe this straight into
|
|
580
|
+
# jq/json.load without stripping prose first.
|
|
581
|
+
print(json.dumps(report_dict, indent=2))
|
|
582
|
+
elif report.skipped:
|
|
583
|
+
print(f"up to date (graph {report.to_version})")
|
|
584
|
+
else:
|
|
585
|
+
print(
|
|
586
|
+
f"synced {report.from_version or '<never>'} -> {report.to_version}: "
|
|
587
|
+
f"{report.counts() or 'no tracked entities'}"
|
|
588
|
+
)
|
|
589
|
+
total_repointed = sum(o.repointed for o in report.outcomes)
|
|
590
|
+
if total_repointed:
|
|
591
|
+
print(f"re-pointed {total_repointed} community binding(s)")
|
|
592
|
+
if report.domains_refreshed:
|
|
593
|
+
print(f"refreshed community mapping for {report.domains_refreshed} domain(s)")
|
|
594
|
+
for o in report.outcomes:
|
|
595
|
+
if o.status in ("moved", "orphaned", "ambiguous", "error"):
|
|
596
|
+
print(f" {o.status}: {o.canonical_name} ({o.detail or 'no match'})")
|
|
597
|
+
if report.stale_decisions:
|
|
598
|
+
print("possibly stale decisions (all anchors gone — verify):")
|
|
599
|
+
for d in report.stale_decisions:
|
|
600
|
+
print(f" {d['id']} {d['title']}")
|
|
601
|
+
if report.empty_domains:
|
|
602
|
+
print("possibly empty domains — re-scope or supersede:")
|
|
603
|
+
for dom in report.empty_domains:
|
|
604
|
+
print(f" {dom['slug']} {dom['title']}")
|
|
605
|
+
if report.overbroad_domains:
|
|
606
|
+
print(
|
|
607
|
+
"path rule too broad — path contribution dropped (seed_anchors, if any, "
|
|
608
|
+
"still applied); narrow path_prefixes or re-scope:"
|
|
609
|
+
)
|
|
610
|
+
for dom in report.overbroad_domains:
|
|
611
|
+
print(
|
|
612
|
+
f" {dom['slug']} {dom['title']} "
|
|
613
|
+
f"({dom['matched']}/{dom['total']} communities)"
|
|
614
|
+
)
|
|
615
|
+
if report.domain_failures:
|
|
616
|
+
print("domains that FAILED to refresh (fix, then re-run with --force):")
|
|
617
|
+
for dom in report.domain_failures:
|
|
618
|
+
print(f" {dom['slug']} {dom['title']} ({dom['error']})")
|
|
619
|
+
if report.slug_conflicts:
|
|
620
|
+
print("slug conflicts — drop one (sidegraph-ratify --drop <loser-id>):")
|
|
621
|
+
for c in report.slug_conflicts:
|
|
622
|
+
ids = ", ".join(c["domain_ids"])
|
|
623
|
+
print(
|
|
624
|
+
f" slug conflict: {c['slug']!r} held by {len(c['domain_ids'])} live "
|
|
625
|
+
f"domains ({ids}) — drop one"
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
if args.check and report_has_findings(report_dict):
|
|
629
|
+
return 2
|
|
630
|
+
return 0
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _import_docs_mode(args: argparse.Namespace) -> int:
|
|
634
|
+
"""``sidegraph-import --docs ...`` — decision-shaped markdown -> anchored decisions
|
|
635
|
+
(importer #2, ``doc_import.import_docs``). Split out from ``import_main`` purely for
|
|
636
|
+
readability; shares every flag except ``--docs``/``--tag``/``--section-limit``
|
|
637
|
+
(docs-only) and ``--path`` (rationale-mode-only, rejected here — see ``import_main``).
|
|
638
|
+
|
|
639
|
+
Existence of every ``--docs`` path is checked BEFORE opening the store/graph (same "no
|
|
640
|
+
I/O side effect on a rejected flag" ordering as the negative-``--limit``/
|
|
641
|
+
``--section-limit`` guards in ``import_main``) — a typo'd path must not create an empty
|
|
642
|
+
store next to the real one.
|
|
643
|
+
|
|
644
|
+
``--profile`` selects the reader dialect and default ingest globs — resolved before any
|
|
645
|
+
store/graph I/O, so an unknown profile name is a clean exit-1; with no explicit PATH, its
|
|
646
|
+
ingest globs are expanded (relative to the current directory) into repo-relative paths.
|
|
647
|
+
"""
|
|
648
|
+
profile_name = args.profile or "generic-adr"
|
|
649
|
+
try:
|
|
650
|
+
profile = get_profile(profile_name)
|
|
651
|
+
except ValueError as e:
|
|
652
|
+
print(str(e))
|
|
653
|
+
return 1
|
|
654
|
+
|
|
655
|
+
# A bare `--docs` (no PATH, legal now that --profile can drive discovery instead) still
|
|
656
|
+
# appends `None` to the list via nargs="?" — filter those out to find any REAL explicit
|
|
657
|
+
# paths; guards `args.docs is None` too (flag omitted entirely, --profile-only mode).
|
|
658
|
+
explicit_docs = [p for p in args.docs if p is not None] if args.docs is not None else []
|
|
659
|
+
if explicit_docs:
|
|
660
|
+
docs_paths: list[str] = explicit_docs
|
|
661
|
+
missing = [p for p in docs_paths if not Path(p).exists()]
|
|
662
|
+
if missing:
|
|
663
|
+
print(f"--docs path(s) not found: {', '.join(missing)}")
|
|
664
|
+
return 1
|
|
665
|
+
else:
|
|
666
|
+
# Profile-only: discover files via the profile's ingest globs, relative to cwd.
|
|
667
|
+
# Profile globs are repo-relative; keep the expanded paths repo-relative too, so
|
|
668
|
+
# provenance.ref / the "imported from" context are deterministic across machines and
|
|
669
|
+
# dedup against an equivalent relative `--docs <path>` run (see cross-invocation
|
|
670
|
+
# idempotency test). Path.cwd().glob(...) always yields paths under cwd, so
|
|
671
|
+
# relative_to(Path.cwd()) never raises.
|
|
672
|
+
docs_paths = sorted(
|
|
673
|
+
str(p.relative_to(Path.cwd()))
|
|
674
|
+
for glob in profile.ingest_globs
|
|
675
|
+
for p in Path.cwd().glob(glob)
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
args.db = resolve_store_path(args.db)
|
|
679
|
+
|
|
680
|
+
try:
|
|
681
|
+
reader = GraphifyReader(args.graph)
|
|
682
|
+
except Exception as e:
|
|
683
|
+
print(f"graph not readable ({args.graph}): {e}")
|
|
684
|
+
return 1
|
|
685
|
+
|
|
686
|
+
try:
|
|
687
|
+
store = Store(args.db)
|
|
688
|
+
except Exception as e:
|
|
689
|
+
print(f"store not readable ({args.db}): {e}")
|
|
690
|
+
return 1
|
|
691
|
+
|
|
692
|
+
# Resolved once here, immediately before the batch call (design D1) — one parse per
|
|
693
|
+
# CLI invocation, never re-read mid-import. Hoisted to a local (not inline in the
|
|
694
|
+
# kwargs dict below) so the result-line printer further down can test it too, with no
|
|
695
|
+
# second env read (Ruling O).
|
|
696
|
+
ratify_policy: RatifyPolicy = parse_ratify_policy(os.environ.get("SIDEGRAPH_RATIFY_POLICY"))
|
|
697
|
+
# `kind=None` -> import_docs auto-detects per document (B4); `section_limit` only
|
|
698
|
+
# overridden when the user actually passed --section-limit, else import_docs's own
|
|
699
|
+
# default (_SECTION_LIMIT) applies.
|
|
700
|
+
import_docs_kwargs: dict = dict(
|
|
701
|
+
kind=args.kind,
|
|
702
|
+
propose=args.propose,
|
|
703
|
+
dry_run=args.dry_run,
|
|
704
|
+
limit=args.limit,
|
|
705
|
+
tags=args.tag,
|
|
706
|
+
profile=profile_name,
|
|
707
|
+
any_doc=args.any_doc,
|
|
708
|
+
ratify_policy=ratify_policy,
|
|
709
|
+
)
|
|
710
|
+
if args.section_limit is not None:
|
|
711
|
+
import_docs_kwargs["section_limit"] = args.section_limit
|
|
712
|
+
report = import_docs(store, reader, docs_paths, **import_docs_kwargs)
|
|
713
|
+
|
|
714
|
+
# BUG F: an absolute `--docs` path is resolved relative to the current working directory
|
|
715
|
+
# to match the graph's repo-relative `source_file`, so running `sidegraph-import` from
|
|
716
|
+
# anywhere but the repo root makes every doc-node anchor miss — yielding a silent
|
|
717
|
+
# "0 imported, N unanchorable". When (almost) every anchor-attempted doc came back
|
|
718
|
+
# unanchorable AND an absolute path was passed, that footgun is the likeliest cause:
|
|
719
|
+
# warn loudly on stderr instead of leaving the empty result unexplained. Guarded on an
|
|
720
|
+
# absolute path being present so the common case (relative paths from the repo root) is
|
|
721
|
+
# byte-identical and never triggers a spurious warning.
|
|
722
|
+
anchor_attempted = report.imported + report.superseded + report.skipped_unanchorable
|
|
723
|
+
if (
|
|
724
|
+
report.skipped_unanchorable > 0
|
|
725
|
+
and anchor_attempted > 0
|
|
726
|
+
and report.skipped_unanchorable / anchor_attempted >= 0.5
|
|
727
|
+
and any(os.path.isabs(p) for p in docs_paths)
|
|
728
|
+
):
|
|
729
|
+
print(
|
|
730
|
+
f"warning: {report.skipped_unanchorable} doc(s) unanchorable — if you passed an "
|
|
731
|
+
"absolute --docs path, run sidegraph-import from the repo root (doc paths are "
|
|
732
|
+
"resolved relative to the current directory, so they only match graph.json's "
|
|
733
|
+
"root-relative source_file entries when the current directory IS the repo root).",
|
|
734
|
+
file=sys.stderr,
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
if args.dry_run:
|
|
738
|
+
for item in report.dry_run:
|
|
739
|
+
# `ref` is the effective ref (`path` or `path#fragment` for a split child) —
|
|
740
|
+
# the per-record identity; `file_path` stays the real path for by_file()'s
|
|
741
|
+
# per-document rollup (oneshot+granularity spec, review F14/F4).
|
|
742
|
+
print(f"{item['ref']}: [{item['action']}] {item['title']}")
|
|
743
|
+
for skip in item["anchors_skipped"]:
|
|
744
|
+
print(f" anchor skipped: {skip['name']} ({skip['reason']})")
|
|
745
|
+
print(
|
|
746
|
+
f"\nwould import {report.imported} decision(s), supersede {report.superseded} "
|
|
747
|
+
f"(skipped: {report.skipped_existing} existing, "
|
|
748
|
+
f"{report.skipped_unanchorable} unanchorable, "
|
|
749
|
+
f"{report.skipped_not_decision} not-decision-shaped, "
|
|
750
|
+
f"{report.skipped_unparseable} unparseable, "
|
|
751
|
+
f"{report.skipped_superseded_frontmatter} superseded-frontmatter, "
|
|
752
|
+
f"{report.skipped_outside_profile} outside-profile)"
|
|
753
|
+
)
|
|
754
|
+
if report.status_derived_rejected:
|
|
755
|
+
print(f"{report.status_derived_rejected} would land rejected (source status: rejected)")
|
|
756
|
+
if report.status_derived_proposed:
|
|
757
|
+
print(
|
|
758
|
+
f"{report.status_derived_proposed} would land proposed "
|
|
759
|
+
"(source status: draft/proposed/pending/under review)"
|
|
760
|
+
)
|
|
761
|
+
if report.skipped_template:
|
|
762
|
+
print(f"{report.skipped_template} skipped as template(s) (not decisions)")
|
|
763
|
+
if report.skipped_degenerate_parent:
|
|
764
|
+
print(
|
|
765
|
+
f"{report.skipped_degenerate_parent} split parent(s) skipped as degenerate "
|
|
766
|
+
"(echoed context or empty choice) — children imported on their own"
|
|
767
|
+
)
|
|
768
|
+
for fp, n in sorted(report.by_file().items()):
|
|
769
|
+
print(f" {fp}: {n}")
|
|
770
|
+
return 0
|
|
771
|
+
|
|
772
|
+
# Design D6: the count is added to the non-dry-run summary line only, only when the
|
|
773
|
+
# resolved policy is not `manual` — the dry-run line above (`report.dry_run`'s own
|
|
774
|
+
# printer) is left untouched.
|
|
775
|
+
auto_segment = (
|
|
776
|
+
f", auto-ratified {report.auto_ratified}" if ratify_policy != RatifyPolicy.MANUAL else ""
|
|
777
|
+
)
|
|
778
|
+
print(
|
|
779
|
+
f"imported {report.imported} decision(s), superseded {report.superseded}{auto_segment} "
|
|
780
|
+
f"(skipped: {report.skipped_existing} existing, "
|
|
781
|
+
f"{report.skipped_unanchorable} unanchorable, "
|
|
782
|
+
f"{report.skipped_not_decision} not-decision-shaped, "
|
|
783
|
+
f"{report.skipped_unparseable} unparseable, "
|
|
784
|
+
f"{report.skipped_superseded_frontmatter} superseded-frontmatter, "
|
|
785
|
+
f"{report.skipped_outside_profile} outside-profile)"
|
|
786
|
+
)
|
|
787
|
+
if report.status_derived_rejected:
|
|
788
|
+
print(f"{report.status_derived_rejected} landed rejected (source status: rejected)")
|
|
789
|
+
if report.status_derived_proposed:
|
|
790
|
+
print(
|
|
791
|
+
f"{report.status_derived_proposed} landed proposed "
|
|
792
|
+
"(source status: draft/proposed/pending/under review)"
|
|
793
|
+
)
|
|
794
|
+
if report.skipped_template:
|
|
795
|
+
print(f"{report.skipped_template} skipped as template(s) (not decisions)")
|
|
796
|
+
if report.skipped_degenerate_parent:
|
|
797
|
+
print(
|
|
798
|
+
f"{report.skipped_degenerate_parent} split parent(s) skipped as degenerate "
|
|
799
|
+
"(echoed context or empty choice) — children imported on their own"
|
|
800
|
+
)
|
|
801
|
+
for entry in report.auto_ratify_failures:
|
|
802
|
+
print(f"auto-ratify failure: {entry}", file=sys.stderr)
|
|
803
|
+
return 0
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def import_main(argv: list[str] | None = None) -> int:
|
|
807
|
+
"""Bootstrap decisions either from Graphify rationale nodes (default), or — with
|
|
808
|
+
``--docs`` — from decision-shaped markdown (importer #2, ``doc_import.import_docs``;
|
|
809
|
+
see docs/reference/cli.md#importing-decision-shaped-markdown---docs).
|
|
810
|
+
|
|
811
|
+
Default mode reads rationale nodes via the reader, writes through
|
|
812
|
+
``importer.import_rationales`` (redact -> validate -> anchor -> write). ``--dry-run``
|
|
813
|
+
lists what would be imported and writes nothing; ``--limit``/``--path`` bound the volume
|
|
814
|
+
on large repos (the test corpus yields 492 rationale nodes) — docs recommend
|
|
815
|
+
``--dry-run`` first. A negative ``--limit`` is a hard usage error (exit 1), same
|
|
816
|
+
convention as the readability checks below. See
|
|
817
|
+
``docs/reference/cli.md#sidegraph-import``.
|
|
818
|
+
|
|
819
|
+
``--docs PATH`` (repeatable; each a file or a directory recursed for ``*.md``) switches
|
|
820
|
+
to importer #2 instead of adding to importer #1's rationale scan — ``--path`` (the
|
|
821
|
+
rationale-mode graph-prefix filter) is meaningless with ``--docs`` and combining them is
|
|
822
|
+
a hard usage error (exit 1); ``--tag``/``--section-limit`` (docs-only) are simply unused
|
|
823
|
+
in rationale mode. Every other flag (``--db``/``--graph``/``--kind``/``--propose``/
|
|
824
|
+
``--dry-run``/``--limit``) is shared.
|
|
825
|
+
|
|
826
|
+
``--kind`` (fix-wave B, B4) defaults to auto in ``--docs`` mode: per document, a
|
|
827
|
+
qualifying "Root cause" section suggests ``lesson``, else ``adr`` — pass ``--kind``
|
|
828
|
+
explicitly (any value, including ``adr``) to pin every document in the run to one kind,
|
|
829
|
+
same as before B4. Rationale mode is unaffected: an omitted ``--kind`` there still means
|
|
830
|
+
plain ``adr`` for every rationale, unconditionally.
|
|
831
|
+
"""
|
|
832
|
+
parser = argparse.ArgumentParser(
|
|
833
|
+
prog="sidegraph-import",
|
|
834
|
+
description="Bootstrap decisions from Graphify rationale nodes (code AST + LLM docs), "
|
|
835
|
+
"or decision-shaped markdown (--docs).",
|
|
836
|
+
)
|
|
837
|
+
parser.add_argument(
|
|
838
|
+
"--db",
|
|
839
|
+
default=None,
|
|
840
|
+
help=_DB_HELP,
|
|
841
|
+
)
|
|
842
|
+
parser.add_argument(
|
|
843
|
+
"--graph", default=os.environ.get("SIDEGRAPH_GRAPH", "graphify-out/graph.json")
|
|
844
|
+
)
|
|
845
|
+
parser.add_argument(
|
|
846
|
+
"--kind",
|
|
847
|
+
choices=[k.value for k in DecisionKind],
|
|
848
|
+
default=None,
|
|
849
|
+
help="Decision kind to assign (default: adr for rationale mode; --docs mode "
|
|
850
|
+
"auto-detects lesson for a doc with a qualifying 'Root cause' section, else adr — "
|
|
851
|
+
"pass explicitly to pin every document to one kind)",
|
|
852
|
+
)
|
|
853
|
+
parser.add_argument(
|
|
854
|
+
"--propose",
|
|
855
|
+
action="store_true",
|
|
856
|
+
help="write as proposed (ratify gate) instead of accepted by default",
|
|
857
|
+
)
|
|
858
|
+
parser.add_argument(
|
|
859
|
+
"--dry-run",
|
|
860
|
+
action="store_true",
|
|
861
|
+
help="list what would be imported; write nothing",
|
|
862
|
+
)
|
|
863
|
+
parser.add_argument("--limit", type=int, default=None, metavar="N")
|
|
864
|
+
parser.add_argument(
|
|
865
|
+
"--path",
|
|
866
|
+
action="append",
|
|
867
|
+
default=None,
|
|
868
|
+
metavar="PREFIX",
|
|
869
|
+
help="only import rationales whose file_path starts with PREFIX (repeatable); "
|
|
870
|
+
"rationale mode only, incompatible with --docs",
|
|
871
|
+
)
|
|
872
|
+
parser.add_argument(
|
|
873
|
+
"--docs",
|
|
874
|
+
nargs="?",
|
|
875
|
+
action="append",
|
|
876
|
+
default=None,
|
|
877
|
+
metavar="PATH",
|
|
878
|
+
help="import decision-shaped markdown instead of rationale nodes: PATH is a file or "
|
|
879
|
+
"a directory (recursed for *.md); repeatable. Bare (no PATH) imports the active "
|
|
880
|
+
"profile's ingest globs instead of explicit paths — generic-adr's by default, or "
|
|
881
|
+
"the profile named by --profile.",
|
|
882
|
+
)
|
|
883
|
+
parser.add_argument(
|
|
884
|
+
"--profile",
|
|
885
|
+
default=None,
|
|
886
|
+
metavar="NAME",
|
|
887
|
+
help="flow-profile selecting the reader dialect and default ingest globs (--docs mode; "
|
|
888
|
+
f"one of: {', '.join(sorted(PROFILES))}). With no explicit PATH, "
|
|
889
|
+
"the profile's ingest globs (relative to the current directory) are used.",
|
|
890
|
+
)
|
|
891
|
+
parser.add_argument(
|
|
892
|
+
"--tag",
|
|
893
|
+
action="append",
|
|
894
|
+
default=None,
|
|
895
|
+
metavar="TAG",
|
|
896
|
+
help="tag every imported/superseded decision with a durable tag:<slug> entity "
|
|
897
|
+
"(repeatable); --docs mode only",
|
|
898
|
+
)
|
|
899
|
+
parser.add_argument(
|
|
900
|
+
"--section-limit",
|
|
901
|
+
type=int,
|
|
902
|
+
default=None,
|
|
903
|
+
metavar="N",
|
|
904
|
+
help="per-field char cap for context/choice/rejected/consequences, applied after "
|
|
905
|
+
f"redaction at a word boundary (default: {_SECTION_LIMIT}; min {_MIN_SECTION_LIMIT}); "
|
|
906
|
+
"--docs mode only",
|
|
907
|
+
)
|
|
908
|
+
parser.add_argument(
|
|
909
|
+
"--any-doc",
|
|
910
|
+
action="store_true",
|
|
911
|
+
help="import any enumerated *.md file regardless of the active profile's "
|
|
912
|
+
"ingest_globs (default: files outside the profile's declared scope are skipped and "
|
|
913
|
+
"counted as outside-profile — --docs mode only)",
|
|
914
|
+
)
|
|
915
|
+
args = parser.parse_args(argv)
|
|
916
|
+
|
|
917
|
+
if args.limit is not None and args.limit < 0:
|
|
918
|
+
print(f"--limit must be >= 0 (got {args.limit})")
|
|
919
|
+
return 1
|
|
920
|
+
if args.section_limit is not None and args.section_limit < _MIN_SECTION_LIMIT:
|
|
921
|
+
print(f"--section-limit must be >= {_MIN_SECTION_LIMIT} (got {args.section_limit})")
|
|
922
|
+
return 1
|
|
923
|
+
|
|
924
|
+
if args.docs is not None or args.profile is not None:
|
|
925
|
+
if args.path is not None:
|
|
926
|
+
print(
|
|
927
|
+
"--docs/--profile and --path are mutually exclusive (--path is the "
|
|
928
|
+
"rationale-mode graph-prefix filter; it has no meaning against markdown files)"
|
|
929
|
+
)
|
|
930
|
+
return 1
|
|
931
|
+
return _import_docs_mode(args)
|
|
932
|
+
|
|
933
|
+
args.db = resolve_store_path(args.db)
|
|
934
|
+
|
|
935
|
+
try:
|
|
936
|
+
reader = GraphifyReader(args.graph)
|
|
937
|
+
except Exception as e:
|
|
938
|
+
print(f"graph not readable ({args.graph}): {e}")
|
|
939
|
+
return 1
|
|
940
|
+
|
|
941
|
+
try:
|
|
942
|
+
store = Store(args.db)
|
|
943
|
+
except Exception as e:
|
|
944
|
+
print(f"store not readable ({args.db}): {e}")
|
|
945
|
+
return 1
|
|
946
|
+
|
|
947
|
+
# Rationale mode ignores --docs-only flags (--tag/--section-limit) and, unlike --docs
|
|
948
|
+
# mode's auto-kind (B4), keeps its own unconditional "adr when omitted" default.
|
|
949
|
+
# Resolved once here, immediately before the batch call (design D1) — one parse per
|
|
950
|
+
# CLI invocation, never re-read mid-import.
|
|
951
|
+
ratify_policy: RatifyPolicy = parse_ratify_policy(os.environ.get("SIDEGRAPH_RATIFY_POLICY"))
|
|
952
|
+
report = import_rationales(
|
|
953
|
+
store,
|
|
954
|
+
reader,
|
|
955
|
+
kind=args.kind or DecisionKind.ADR.value,
|
|
956
|
+
propose=args.propose,
|
|
957
|
+
dry_run=args.dry_run,
|
|
958
|
+
limit=args.limit,
|
|
959
|
+
path_prefixes=args.path,
|
|
960
|
+
ratify_policy=ratify_policy,
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
if args.dry_run:
|
|
964
|
+
for item in report.dry_run:
|
|
965
|
+
print(f"{item['file_path'] or item['node_id']}: {item['title']}")
|
|
966
|
+
print(
|
|
967
|
+
f"\nwould import {report.imported} decision(s) "
|
|
968
|
+
f"(skipped: {report.skipped_existing} existing, "
|
|
969
|
+
f"{report.skipped_unanchorable} unanchorable, {report.filtered} filtered)"
|
|
970
|
+
)
|
|
971
|
+
for fp, n in sorted(report.by_file().items()):
|
|
972
|
+
print(f" {fp}: {n}")
|
|
973
|
+
return 0
|
|
974
|
+
|
|
975
|
+
auto_segment = (
|
|
976
|
+
f", auto-ratified {report.auto_ratified}" if ratify_policy != RatifyPolicy.MANUAL else ""
|
|
977
|
+
)
|
|
978
|
+
print(
|
|
979
|
+
f"imported {report.imported} decision(s){auto_segment} "
|
|
980
|
+
f"(skipped: {report.skipped_existing} existing, "
|
|
981
|
+
f"{report.skipped_unanchorable} unanchorable)"
|
|
982
|
+
)
|
|
983
|
+
for entry in report.auto_ratify_failures:
|
|
984
|
+
print(f"auto-ratify failure: {entry}", file=sys.stderr)
|
|
985
|
+
return 0
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _limit_truncation_note(effective_limit: int | None, total_before_limit: int) -> str | None:
|
|
989
|
+
"""The shared stderr line for "``--limit`` (default or explicit) cut off candidates
|
|
990
|
+
this run never even considered" (finding B/C) — used both by the real run's before-
|
|
991
|
+
write pre-check and by ``--dry-run``'s listing, so the wording never drifts between the
|
|
992
|
+
two. ``None`` when nothing was actually truncated (``effective_limit`` unset, i.e. the
|
|
993
|
+
"all" convention, or the full significant count already fit under it)."""
|
|
994
|
+
if effective_limit is None or total_before_limit <= effective_limit:
|
|
995
|
+
return None
|
|
996
|
+
return (
|
|
997
|
+
f"note: {total_before_limit} significant communities found — showing only the top "
|
|
998
|
+
f"{effective_limit} (community-id order); pass --limit 0 for the full list, a "
|
|
999
|
+
"higher --limit N, or narrow with --min-members/--paths"
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _domains_bootstrap(args: argparse.Namespace) -> int:
|
|
1004
|
+
"""``sidegraph-domains bootstrap`` — propose Domains from graph communities (§4.1).
|
|
1005
|
+
|
|
1006
|
+
Validation happens BEFORE ``resolve_store_path``/``Store(...)`` (same ordering as
|
|
1007
|
+
``import_main``'s ``--limit`` guard) so a rejected flag never creates a store as a
|
|
1008
|
+
side effect.
|
|
1009
|
+
|
|
1010
|
+
``--limit`` defaults to ``DEFAULT_CANDIDATE_LIMIT`` (100, finding B) — the same
|
|
1011
|
+
scale-aware default the ``list_domain_candidates`` MCP tool applies, so the two never
|
|
1012
|
+
disagree about what "a normal run" writes. ``--limit 0`` is the "all" convention
|
|
1013
|
+
(mirrors the tool's ``limit=0``), translated to the collector's own ``None``
|
|
1014
|
+
(unlimited) before it reaches ``bootstrap_domains``.
|
|
1015
|
+
|
|
1016
|
+
BUG C: on a non-dry-run, the over-threshold/truncation nags are computed from a
|
|
1017
|
+
SEPARATE, read-only ``collect_domain_candidates`` pre-check and printed BEFORE
|
|
1018
|
+
``bootstrap_domains`` writes a single record — never after, which is what let a real
|
|
1019
|
+
run flood the store with thousands of proposed-domain records before the old nag
|
|
1020
|
+
(computed from the already-finished report) ever printed.
|
|
1021
|
+
"""
|
|
1022
|
+
if args.min_members < 1:
|
|
1023
|
+
print(f"--min-members must be >= 1 (got {args.min_members})")
|
|
1024
|
+
return 1
|
|
1025
|
+
if args.limit is not None and args.limit < 0:
|
|
1026
|
+
print(f"--limit must be >= 0 (got {args.limit})")
|
|
1027
|
+
return 1
|
|
1028
|
+
|
|
1029
|
+
args.db = resolve_store_path(args.db)
|
|
1030
|
+
|
|
1031
|
+
try:
|
|
1032
|
+
reader = GraphifyReader(args.graph)
|
|
1033
|
+
except Exception as e:
|
|
1034
|
+
print(f"graph not readable ({args.graph}): {e}")
|
|
1035
|
+
return 1
|
|
1036
|
+
|
|
1037
|
+
try:
|
|
1038
|
+
store = Store(args.db)
|
|
1039
|
+
except Exception as e:
|
|
1040
|
+
print(f"store not readable ({args.db}): {e}")
|
|
1041
|
+
return 1
|
|
1042
|
+
|
|
1043
|
+
effective_limit = None if args.limit == 0 else args.limit
|
|
1044
|
+
|
|
1045
|
+
if not args.dry_run:
|
|
1046
|
+
_, precheck_stats = collect_domain_candidates(
|
|
1047
|
+
store, reader, min_members=args.min_members, paths=args.paths, limit=effective_limit
|
|
1048
|
+
)
|
|
1049
|
+
note = _limit_truncation_note(effective_limit, precheck_stats.total_before_limit)
|
|
1050
|
+
if note is not None:
|
|
1051
|
+
print(note, file=sys.stderr)
|
|
1052
|
+
if precheck_stats.total > _BOOTSTRAP_LARGE_RUN_THRESHOLD:
|
|
1053
|
+
print(
|
|
1054
|
+
f"note: about to propose {precheck_stats.total} domains — consider "
|
|
1055
|
+
"--min-members/--limit and ratify selectively",
|
|
1056
|
+
file=sys.stderr,
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
# Resolved once here, immediately before the batch call (design D1) — one parse per
|
|
1060
|
+
# CLI invocation, never re-read mid-bootstrap.
|
|
1061
|
+
ratify_policy: RatifyPolicy = parse_ratify_policy(os.environ.get("SIDEGRAPH_RATIFY_POLICY"))
|
|
1062
|
+
report = bootstrap_domains(
|
|
1063
|
+
store,
|
|
1064
|
+
reader,
|
|
1065
|
+
min_members=args.min_members,
|
|
1066
|
+
paths=args.paths,
|
|
1067
|
+
dry_run=args.dry_run,
|
|
1068
|
+
limit=effective_limit,
|
|
1069
|
+
ratify_policy=ratify_policy,
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
if args.dry_run:
|
|
1073
|
+
for item in report.dry_run:
|
|
1074
|
+
print(f"{item['community_id']}: {item['slug']} — {item['title']}")
|
|
1075
|
+
for w in item["warnings"]:
|
|
1076
|
+
print(f" warning: {w}")
|
|
1077
|
+
print(
|
|
1078
|
+
f"\nwould propose {report.proposed} domain(s) "
|
|
1079
|
+
f"(skipped: {report.skipped_existing} existing, "
|
|
1080
|
+
f"{report.below_threshold} below threshold, {report.filtered} filtered)"
|
|
1081
|
+
)
|
|
1082
|
+
note = _limit_truncation_note(effective_limit, report.total_before_limit)
|
|
1083
|
+
if note is not None:
|
|
1084
|
+
print(note, file=sys.stderr)
|
|
1085
|
+
return 0
|
|
1086
|
+
|
|
1087
|
+
auto_segment = (
|
|
1088
|
+
f", auto-ratified {report.auto_ratified}" if ratify_policy != RatifyPolicy.MANUAL else ""
|
|
1089
|
+
)
|
|
1090
|
+
print(
|
|
1091
|
+
f"proposed {report.proposed} domain(s){auto_segment} "
|
|
1092
|
+
f"(skipped: {report.skipped_existing} existing)"
|
|
1093
|
+
)
|
|
1094
|
+
for entry in report.warnings:
|
|
1095
|
+
for w in entry["warnings"]:
|
|
1096
|
+
print(f" warning ({entry['slug']}): {w}")
|
|
1097
|
+
for failure in report.auto_ratify_failures:
|
|
1098
|
+
print(f"auto-ratify failure: {failure}", file=sys.stderr)
|
|
1099
|
+
return 0
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _domains_add(args: argparse.Namespace) -> int:
|
|
1103
|
+
"""``sidegraph-domains add`` — manually propose a Domain (§4.3, manual path); mirrors
|
|
1104
|
+
``server._add_domain_impl`` (always lands ``status=proposed`` — manual authoring is not
|
|
1105
|
+
an exception to the ratification gate).
|
|
1106
|
+
|
|
1107
|
+
A slug collision against a *live* (proposed|accepted) domain is reported as a skip
|
|
1108
|
+
(exit 0), same "expected outcome, not a hard failure" treatment as bootstrap's
|
|
1109
|
+
idempotency skip — only an unreadable store, an unresolvable ``--parent``, or an
|
|
1110
|
+
invalid draft (e.g. non-kebab-case ``--slug``) is a hard failure (exit 1). Draft shape
|
|
1111
|
+
is validated BEFORE any store is opened (same "no I/O side effect on a rejected flag"
|
|
1112
|
+
ordering as ``import_main``'s ``--limit`` guard) — a bad ``--slug`` must not create an
|
|
1113
|
+
empty store next to the real one.
|
|
1114
|
+
"""
|
|
1115
|
+
try:
|
|
1116
|
+
Domain(
|
|
1117
|
+
slug=args.slug,
|
|
1118
|
+
title=args.title,
|
|
1119
|
+
summary=args.summary,
|
|
1120
|
+
path_prefixes=args.path or [],
|
|
1121
|
+
provenance=Provenance(source="manual"),
|
|
1122
|
+
)
|
|
1123
|
+
except ValidationError as e:
|
|
1124
|
+
print(f"error: {e}")
|
|
1125
|
+
return 1
|
|
1126
|
+
|
|
1127
|
+
args.db = resolve_store_path(args.db)
|
|
1128
|
+
|
|
1129
|
+
try:
|
|
1130
|
+
store = Store(args.db)
|
|
1131
|
+
except Exception as e:
|
|
1132
|
+
print(f"store not readable ({args.db}): {e}")
|
|
1133
|
+
return 1
|
|
1134
|
+
|
|
1135
|
+
parent_id = None
|
|
1136
|
+
if args.parent is not None:
|
|
1137
|
+
parent = store.find_domain_by_slug(args.parent)
|
|
1138
|
+
if parent is None:
|
|
1139
|
+
print(f"error: --parent {args.parent!r} does not resolve to any domain")
|
|
1140
|
+
return 1
|
|
1141
|
+
parent_id = parent.domain_id
|
|
1142
|
+
|
|
1143
|
+
domain = Domain(
|
|
1144
|
+
slug=args.slug,
|
|
1145
|
+
title=args.title,
|
|
1146
|
+
summary=args.summary,
|
|
1147
|
+
parent_id=parent_id,
|
|
1148
|
+
path_prefixes=args.path or [],
|
|
1149
|
+
provenance=Provenance(source="manual"),
|
|
1150
|
+
)
|
|
1151
|
+
try:
|
|
1152
|
+
store.add_domain(domain)
|
|
1153
|
+
except ValueError as e:
|
|
1154
|
+
print(f"proposed 0 domain(s) (skipped: 1 existing — {e})")
|
|
1155
|
+
return 0
|
|
1156
|
+
|
|
1157
|
+
print("proposed 1 domain(s) (skipped: 0 existing)")
|
|
1158
|
+
print(f"{domain.domain_id} {domain.slug}")
|
|
1159
|
+
return 0
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def domains_main(argv: list[str] | None = None) -> int:
|
|
1163
|
+
"""``sidegraph-domains`` — author Domain proposals (mind-model layer; see
|
|
1164
|
+
docs/concepts/mind-model.md). Two subcommands:
|
|
1165
|
+
``bootstrap`` (path 1, from graph communities) and ``add`` (path 3, manual). Both land
|
|
1166
|
+
``status=proposed`` — ``sidegraph-ratify`` is the one gate for every authoring path.
|
|
1167
|
+
"""
|
|
1168
|
+
parser = argparse.ArgumentParser(
|
|
1169
|
+
prog="sidegraph-domains",
|
|
1170
|
+
description="Author Domain proposals: bootstrap from graph communities, or add manually.",
|
|
1171
|
+
)
|
|
1172
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1173
|
+
|
|
1174
|
+
boot = sub.add_parser("bootstrap", help="Propose domains from graph communities.")
|
|
1175
|
+
boot.add_argument(
|
|
1176
|
+
"--db",
|
|
1177
|
+
default=None,
|
|
1178
|
+
help=_DB_HELP,
|
|
1179
|
+
)
|
|
1180
|
+
boot.add_argument(
|
|
1181
|
+
"--graph", default=os.environ.get("SIDEGRAPH_GRAPH", "graphify-out/graph.json")
|
|
1182
|
+
)
|
|
1183
|
+
boot.add_argument(
|
|
1184
|
+
"--min-members",
|
|
1185
|
+
type=int,
|
|
1186
|
+
default=5,
|
|
1187
|
+
metavar="N",
|
|
1188
|
+
help="minimum anchorable member count for a community to be proposed (default: 5)",
|
|
1189
|
+
)
|
|
1190
|
+
boot.add_argument(
|
|
1191
|
+
"--paths",
|
|
1192
|
+
action="append",
|
|
1193
|
+
default=None,
|
|
1194
|
+
metavar="PREFIX",
|
|
1195
|
+
help="only consider communities with a member whose file_path starts with PREFIX "
|
|
1196
|
+
"(repeatable)",
|
|
1197
|
+
)
|
|
1198
|
+
boot.add_argument(
|
|
1199
|
+
"--limit",
|
|
1200
|
+
type=int,
|
|
1201
|
+
default=DEFAULT_CANDIDATE_LIMIT,
|
|
1202
|
+
metavar="N",
|
|
1203
|
+
help=f"cap the number of significant communities considered, top-N by community id "
|
|
1204
|
+
f"(default: {DEFAULT_CANDIDATE_LIMIT}; 0 = unlimited, the full list); must be >= 0",
|
|
1205
|
+
)
|
|
1206
|
+
boot.add_argument(
|
|
1207
|
+
"--dry-run",
|
|
1208
|
+
action="store_true",
|
|
1209
|
+
help="list what would be proposed; write nothing",
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
add = sub.add_parser("add", help="Manually propose a domain.")
|
|
1213
|
+
add.add_argument(
|
|
1214
|
+
"--db",
|
|
1215
|
+
default=None,
|
|
1216
|
+
help=_DB_HELP,
|
|
1217
|
+
)
|
|
1218
|
+
add.add_argument("--slug", required=True)
|
|
1219
|
+
add.add_argument("--title", required=True)
|
|
1220
|
+
add.add_argument("--summary", required=True)
|
|
1221
|
+
add.add_argument("--parent", default=None, metavar="SLUG")
|
|
1222
|
+
add.add_argument(
|
|
1223
|
+
"--path",
|
|
1224
|
+
action="append",
|
|
1225
|
+
default=None,
|
|
1226
|
+
metavar="PREFIX",
|
|
1227
|
+
help="path_prefixes stabilizer/bootstrap membership rule (repeatable)",
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
args = parser.parse_args(argv)
|
|
1231
|
+
|
|
1232
|
+
if args.command == "bootstrap":
|
|
1233
|
+
return _domains_bootstrap(args)
|
|
1234
|
+
return _domains_add(args)
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def compact_main(argv: list[str] | None = None) -> int:
|
|
1238
|
+
"""``sidegraph-compact`` — pack terminal-status (superseded/rejected/deprecated
|
|
1239
|
+
decisions; superseded/dropped domains) into an immutable
|
|
1240
|
+
``archive/<date>-<seq>-<hash12>.jsonl`` segment via ``Store.compact`` and remove their
|
|
1241
|
+
now-redundant hot canonical files (see
|
|
1242
|
+
``docs/reference/store-format.md#archive-segments-sidegraph-compact`` — the ``<hash12>``
|
|
1243
|
+
content-hash suffix exists so two branches compacting on the same day never collide on
|
|
1244
|
+
filename). Explicit, human-run maintenance: recommended on the default branch; never
|
|
1245
|
+
wired into sync/retrieval/ratify.
|
|
1246
|
+
|
|
1247
|
+
``--older-than N`` additionally requires N days in a terminal state (a decision's
|
|
1248
|
+
``valid_to``; domains have no such field at all and are conservatively excluded —
|
|
1249
|
+
reported separately as "terminal age unknown" — whenever this flag is set; see
|
|
1250
|
+
``Store.compact``'s docstring). ``--dry-run`` lists what would be compacted (and what
|
|
1251
|
+
leftover hot files from an interrupted prior run would be cleaned up) without writing
|
|
1252
|
+
or removing anything.
|
|
1253
|
+
"""
|
|
1254
|
+
parser = argparse.ArgumentParser(
|
|
1255
|
+
prog="sidegraph-compact",
|
|
1256
|
+
description="Pack terminal-status decisions and domains into an immutable archive "
|
|
1257
|
+
"segment, freeing them from the git-committed record-per-file directories. "
|
|
1258
|
+
"Append-only: records MOVE to the archive, never lost or mutated.",
|
|
1259
|
+
)
|
|
1260
|
+
parser.add_argument(
|
|
1261
|
+
"--db",
|
|
1262
|
+
default=None,
|
|
1263
|
+
help=_DB_HELP,
|
|
1264
|
+
)
|
|
1265
|
+
parser.add_argument(
|
|
1266
|
+
"--older-than",
|
|
1267
|
+
type=int,
|
|
1268
|
+
default=None,
|
|
1269
|
+
metavar="N",
|
|
1270
|
+
help="only compact records that have been in a terminal state for at least N days "
|
|
1271
|
+
"(uses a decision's valid_to; domains carry no terminal timestamp at all and are "
|
|
1272
|
+
"conservatively excluded — kept hot — whenever this flag is set)",
|
|
1273
|
+
)
|
|
1274
|
+
parser.add_argument(
|
|
1275
|
+
"--dry-run",
|
|
1276
|
+
action="store_true",
|
|
1277
|
+
help="list what would be compacted; write nothing",
|
|
1278
|
+
)
|
|
1279
|
+
args = parser.parse_args(argv)
|
|
1280
|
+
|
|
1281
|
+
if args.older_than is not None and args.older_than < 0:
|
|
1282
|
+
print(f"--older-than must be >= 0 (got {args.older_than})")
|
|
1283
|
+
return 1
|
|
1284
|
+
|
|
1285
|
+
args.db = resolve_store_path(args.db)
|
|
1286
|
+
|
|
1287
|
+
try:
|
|
1288
|
+
store = Store(args.db)
|
|
1289
|
+
except Exception as e:
|
|
1290
|
+
print(f"store not readable ({args.db}): {e}")
|
|
1291
|
+
return 1
|
|
1292
|
+
|
|
1293
|
+
report = store.compact(older_than_days=args.older_than, dry_run=args.dry_run)
|
|
1294
|
+
|
|
1295
|
+
if args.dry_run:
|
|
1296
|
+
for item in report.items:
|
|
1297
|
+
print(f"{item.ulid} {item.kind} {item.status} {item.title}")
|
|
1298
|
+
if (
|
|
1299
|
+
not report.items
|
|
1300
|
+
and report.skipped_age_filtered == 0
|
|
1301
|
+
and report.domains_excluded_age_unknown == 0
|
|
1302
|
+
and report.cleaned_up_hot_files == 0
|
|
1303
|
+
):
|
|
1304
|
+
print("nothing to compact")
|
|
1305
|
+
return 0
|
|
1306
|
+
print(
|
|
1307
|
+
f"\nwould compact {report.total_compacted} record(s) "
|
|
1308
|
+
f"({report.decisions_compacted} decisions, {report.domains_compacted} domains); "
|
|
1309
|
+
f"{report.skipped_age_filtered} skipped (not terminal enough)"
|
|
1310
|
+
)
|
|
1311
|
+
if report.domains_excluded_age_unknown:
|
|
1312
|
+
print(f"{report.domains_excluded_age_unknown} domain(s) excluded: terminal age unknown")
|
|
1313
|
+
if report.cleaned_up_hot_files:
|
|
1314
|
+
print(
|
|
1315
|
+
f"would also remove {report.cleaned_up_hot_files} leftover hot file(s) "
|
|
1316
|
+
"already durably archived by a prior, interrupted compact run"
|
|
1317
|
+
)
|
|
1318
|
+
return 0
|
|
1319
|
+
|
|
1320
|
+
if report.total_compacted == 0 and report.cleaned_up_hot_files == 0:
|
|
1321
|
+
parts = []
|
|
1322
|
+
if report.skipped_age_filtered:
|
|
1323
|
+
parts.append(f"{report.skipped_age_filtered} skipped (not terminal enough)")
|
|
1324
|
+
if report.domains_excluded_age_unknown:
|
|
1325
|
+
parts.append(
|
|
1326
|
+
f"{report.domains_excluded_age_unknown} domain(s) excluded: terminal age unknown"
|
|
1327
|
+
)
|
|
1328
|
+
if parts:
|
|
1329
|
+
print("nothing to compact; " + "; ".join(parts))
|
|
1330
|
+
else:
|
|
1331
|
+
print("nothing to compact")
|
|
1332
|
+
return 0
|
|
1333
|
+
|
|
1334
|
+
if report.segment_path:
|
|
1335
|
+
print(
|
|
1336
|
+
f"compacted {report.total_compacted} record(s) into {report.segment_path} "
|
|
1337
|
+
f"({report.decisions_compacted} decisions, {report.domains_compacted} domains); "
|
|
1338
|
+
f"{report.skipped_age_filtered} skipped (not terminal enough)"
|
|
1339
|
+
)
|
|
1340
|
+
else:
|
|
1341
|
+
print(f"compacted 0 record(s); {report.skipped_age_filtered} skipped (not terminal enough)")
|
|
1342
|
+
if report.domains_excluded_age_unknown:
|
|
1343
|
+
print(f"{report.domains_excluded_age_unknown} domain(s) excluded: terminal age unknown")
|
|
1344
|
+
if report.cleaned_up_hot_files:
|
|
1345
|
+
print(
|
|
1346
|
+
f"cleaned up {report.cleaned_up_hot_files} leftover hot file(s) from an "
|
|
1347
|
+
"interrupted prior compact run"
|
|
1348
|
+
)
|
|
1349
|
+
return 0
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def verify_main(argv: list[str] | None = None) -> int:
|
|
1353
|
+
"""``sidegraph-verify`` — store integrity lint (design/superpowers/specs/
|
|
1354
|
+
2026-07-11-ci-integrity-design.md ruling 2). Two layers, combined into one report:
|
|
1355
|
+
|
|
1356
|
+
- **Snapshot layer** (always runs): reads the store's canonical files directly
|
|
1357
|
+
(``verify.verify_snapshot`` — pure read, no ``Store()``, so a lint run can never
|
|
1358
|
+
itself write to ``index.db`` or migrate a legacy store) and reports every schema/
|
|
1359
|
+
referential-integrity violation found.
|
|
1360
|
+
- **Transition layer** (``--against <git-ref>``, additive): ``verify.verify_against``
|
|
1361
|
+
classifies every store file that changed vs ``<git-ref>`` against the store's OWN
|
|
1362
|
+
write rules (git plumbing via subprocess — ``git diff``/``git show``). Its violations
|
|
1363
|
+
are appended to the snapshot layer's, under the same report and exit contract.
|
|
1364
|
+
|
|
1365
|
+
Unlike every other subcommand here, a missing/unreadable store directory is NOT
|
|
1366
|
+
auto-created: ``verify_snapshot`` raises, and that is the operational-error exit path
|
|
1367
|
+
(1) — a lint has nothing to lint if there is nothing to open, and silently creating an
|
|
1368
|
+
empty store just to report "clean" would be actively misleading in CI. Likewise,
|
|
1369
|
+
``--against`` on a store dir outside any git repository, or against an unresolvable
|
|
1370
|
+
``<git-ref>``, is also an operational error (exit 1) — never a violation (design ruling
|
|
1371
|
+
2: "Not-a-git-repo / unknown ref -> operational error").
|
|
1372
|
+
|
|
1373
|
+
``--json`` prints ``{"clean": bool, "violations": [{"code", "path", "detail"}, ...]}`` as
|
|
1374
|
+
one JSON object to stdout — nothing else — same "pure JSON on stdout" convention as
|
|
1375
|
+
``sidegraph-sync --json``. Human output (the default) is one line per violation,
|
|
1376
|
+
``<code> <path> <detail>``, then a one-line summary.
|
|
1377
|
+
|
|
1378
|
+
Exit contract: 0 clean, 1 operational error (unreadable store dir / bad git ref), 2
|
|
1379
|
+
violations found.
|
|
1380
|
+
"""
|
|
1381
|
+
parser = argparse.ArgumentParser(
|
|
1382
|
+
prog="sidegraph-verify",
|
|
1383
|
+
description="Lint the decision store's canonical files against its write-path "
|
|
1384
|
+
"invariants (schema, validity windows, supersedes chains, referential integrity, "
|
|
1385
|
+
"ULID uniqueness across hot files and archive segments); --against additionally "
|
|
1386
|
+
"checks that every store file changed vs a git ref was mutated legally.",
|
|
1387
|
+
)
|
|
1388
|
+
parser.add_argument(
|
|
1389
|
+
"--db",
|
|
1390
|
+
default=None,
|
|
1391
|
+
help=_DB_HELP,
|
|
1392
|
+
)
|
|
1393
|
+
parser.add_argument(
|
|
1394
|
+
"--against",
|
|
1395
|
+
default=None,
|
|
1396
|
+
metavar="GIT_REF",
|
|
1397
|
+
help="also run the transition layer: classify every store file changed vs GIT_REF "
|
|
1398
|
+
"against the store's own write rules (git diff/git show; CI mode)",
|
|
1399
|
+
)
|
|
1400
|
+
parser.add_argument(
|
|
1401
|
+
"--json",
|
|
1402
|
+
action="store_true",
|
|
1403
|
+
help="print {'clean', 'violations'} as one JSON object to stdout (nothing else)",
|
|
1404
|
+
)
|
|
1405
|
+
args = parser.parse_args(argv)
|
|
1406
|
+
args.db = resolve_store_path(args.db, warn_on_create=False)
|
|
1407
|
+
|
|
1408
|
+
try:
|
|
1409
|
+
violations = verify_snapshot(args.db)
|
|
1410
|
+
except Exception as e:
|
|
1411
|
+
print(f"store not readable ({args.db}): {e}")
|
|
1412
|
+
return 1
|
|
1413
|
+
|
|
1414
|
+
if args.against is not None:
|
|
1415
|
+
try:
|
|
1416
|
+
violations = violations + verify_against(args.db, args.against)
|
|
1417
|
+
except Exception as e:
|
|
1418
|
+
print(f"verify --against {args.against!r} failed: {e}")
|
|
1419
|
+
return 1
|
|
1420
|
+
|
|
1421
|
+
if args.json:
|
|
1422
|
+
# Pure JSON on stdout — nothing else — mirrors sidegraph-sync --json.
|
|
1423
|
+
print(
|
|
1424
|
+
json.dumps(
|
|
1425
|
+
{
|
|
1426
|
+
"clean": not violations,
|
|
1427
|
+
"violations": [
|
|
1428
|
+
{"code": v.code, "path": v.path, "detail": v.detail} for v in violations
|
|
1429
|
+
],
|
|
1430
|
+
},
|
|
1431
|
+
indent=2,
|
|
1432
|
+
)
|
|
1433
|
+
)
|
|
1434
|
+
else:
|
|
1435
|
+
for v in violations:
|
|
1436
|
+
print(f"{v.code} {v.path} {v.detail}")
|
|
1437
|
+
if violations:
|
|
1438
|
+
print(f"{len(violations)} violation(s)")
|
|
1439
|
+
else:
|
|
1440
|
+
print("clean")
|
|
1441
|
+
|
|
1442
|
+
return 2 if violations else 0
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def doctor_main(argv: list[str] | None = None) -> int:
|
|
1446
|
+
"""``sidegraph-doctor`` — one-stop store health: strict verify + advisory curation.
|
|
1447
|
+
|
|
1448
|
+
See design/superpowers/specs/2026-07-23-sidegraph-doctor-design.md. Composes the two
|
|
1449
|
+
halves rather than reimplementing either:
|
|
1450
|
+
|
|
1451
|
+
- **Strict section**: ``verify.verify_snapshot`` (+ ``verify.verify_against`` when
|
|
1452
|
+
``--against`` is given) — exactly what ``sidegraph-verify`` runs, same
|
|
1453
|
+
operational-error rules (a missing store is never auto-created; a bad git ref or a
|
|
1454
|
+
store outside any git repo is exit 1, never a violation).
|
|
1455
|
+
- **Advisory section**: ``doctor.curate`` — curation findings that never gate by
|
|
1456
|
+
default; ``--check`` escalates them to exit 2, mirroring ``sidegraph-sync
|
|
1457
|
+
--check``. A skipped check (no usable index.db) affects neither exit code nor
|
|
1458
|
+
cleanliness, even under ``--check``.
|
|
1459
|
+
|
|
1460
|
+
``--json`` prints ``{"clean", "violations", "findings", "skipped"}`` as one JSON
|
|
1461
|
+
object to stdout — nothing else (the sidegraph-verify/-sync convention). ``clean``
|
|
1462
|
+
is true only when violations AND findings are both empty. Exit contract: 0 healthy
|
|
1463
|
+
(advisory findings alone stay 0 without ``--check``), 1 operational error (including
|
|
1464
|
+
a negative ``--stale-days``, rejected before the store is touched — same hard-usage
|
|
1465
|
+
treatment as ``sidegraph-import --limit``), 2 strict violations or, with ``--check``,
|
|
1466
|
+
advisory findings.
|
|
1467
|
+
"""
|
|
1468
|
+
parser = argparse.ArgumentParser(
|
|
1469
|
+
prog="sidegraph-doctor",
|
|
1470
|
+
description="Store health: sidegraph-verify's strict snapshot (+ optional "
|
|
1471
|
+
"--against transition layer) plus advisory curation lint (dangling records, "
|
|
1472
|
+
"decayed bindings, stale proposals, unreferenced entities, expired-but-open "
|
|
1473
|
+
"validity). Advisory findings never fail the run unless --check.",
|
|
1474
|
+
)
|
|
1475
|
+
parser.add_argument("--db", default=None, help=_DB_HELP)
|
|
1476
|
+
parser.add_argument(
|
|
1477
|
+
"--against",
|
|
1478
|
+
default=None,
|
|
1479
|
+
metavar="GIT_REF",
|
|
1480
|
+
help="also run verify's transition layer vs GIT_REF (same semantics as "
|
|
1481
|
+
"sidegraph-verify --against)",
|
|
1482
|
+
)
|
|
1483
|
+
parser.add_argument(
|
|
1484
|
+
"--check",
|
|
1485
|
+
action="store_true",
|
|
1486
|
+
help="advisory findings also exit 2 (default: report only; skipped checks never fail)",
|
|
1487
|
+
)
|
|
1488
|
+
parser.add_argument(
|
|
1489
|
+
"--stale-days",
|
|
1490
|
+
type=int,
|
|
1491
|
+
default=30,
|
|
1492
|
+
metavar="N",
|
|
1493
|
+
help="stale-proposal threshold: flag proposals strictly older than N days (default 30)",
|
|
1494
|
+
)
|
|
1495
|
+
parser.add_argument(
|
|
1496
|
+
"--json",
|
|
1497
|
+
action="store_true",
|
|
1498
|
+
help="print {'clean','violations','findings','skipped'} as one JSON object to "
|
|
1499
|
+
"stdout (nothing else)",
|
|
1500
|
+
)
|
|
1501
|
+
args = parser.parse_args(argv)
|
|
1502
|
+
if args.stale_days < 0:
|
|
1503
|
+
# Reject before touching the store — a bad flag must not affect anything.
|
|
1504
|
+
print(f"--stale-days must be >= 0, got {args.stale_days}")
|
|
1505
|
+
return 1
|
|
1506
|
+
args.db = resolve_store_path(args.db, warn_on_create=False)
|
|
1507
|
+
|
|
1508
|
+
try:
|
|
1509
|
+
violations = verify_snapshot(args.db)
|
|
1510
|
+
except Exception as e:
|
|
1511
|
+
print(f"store not readable ({args.db}): {e}")
|
|
1512
|
+
return 1
|
|
1513
|
+
if args.against is not None:
|
|
1514
|
+
try:
|
|
1515
|
+
violations = violations + verify_against(args.db, args.against)
|
|
1516
|
+
except Exception as e:
|
|
1517
|
+
print(f"doctor --against {args.against!r} failed: {e}")
|
|
1518
|
+
return 1
|
|
1519
|
+
|
|
1520
|
+
report = curate(args.db, stale_days=args.stale_days)
|
|
1521
|
+
|
|
1522
|
+
if args.json:
|
|
1523
|
+
# Pure JSON on stdout — nothing else — mirrors sidegraph-verify --json.
|
|
1524
|
+
print(
|
|
1525
|
+
json.dumps(
|
|
1526
|
+
{
|
|
1527
|
+
"clean": not violations and not report.findings,
|
|
1528
|
+
"violations": [
|
|
1529
|
+
{"code": v.code, "path": v.path, "detail": v.detail} for v in violations
|
|
1530
|
+
],
|
|
1531
|
+
"findings": [
|
|
1532
|
+
{"code": f.code, "path": f.path, "detail": f.detail}
|
|
1533
|
+
for f in report.findings
|
|
1534
|
+
],
|
|
1535
|
+
"skipped": report.skipped,
|
|
1536
|
+
},
|
|
1537
|
+
indent=2,
|
|
1538
|
+
)
|
|
1539
|
+
)
|
|
1540
|
+
else:
|
|
1541
|
+
for v in violations:
|
|
1542
|
+
print(f"{v.code} {v.path} {v.detail}")
|
|
1543
|
+
for f in report.findings:
|
|
1544
|
+
print(f"{f.code} {f.path} {f.detail}")
|
|
1545
|
+
for name in report.skipped:
|
|
1546
|
+
print(f"{name} check skipped (no usable index.db — run sidegraph-sync)")
|
|
1547
|
+
# Ratification latency (2026-08-04 lifecycle D4 follow-up, practitioner
|
|
1548
|
+
# resolution 1.2): median/max ratified_at − valid_from over records that carry
|
|
1549
|
+
# the stamp. Pre-stamp records are excluded, never guessed; no stamped records →
|
|
1550
|
+
# no line. Informational only: never a finding, never affects the exit code, and
|
|
1551
|
+
# deliberately absent from --json (whose key set is a pinned contract).
|
|
1552
|
+
try:
|
|
1553
|
+
from .doctor import _iter_records, _parse_aware_iso
|
|
1554
|
+
|
|
1555
|
+
stamps = []
|
|
1556
|
+
for subdir in ("decisions", "facts", "domains"):
|
|
1557
|
+
for _path, rec in _iter_records(Path(args.db), subdir):
|
|
1558
|
+
# D5: auto stamps excluded -- ratified_at ~= valid_from for those
|
|
1559
|
+
# would collapse the median toward 0 and it would stop measuring
|
|
1560
|
+
# human queue latency.
|
|
1561
|
+
if isinstance(rb := rec.get("ratified_by"), str) and rb.startswith("auto:"):
|
|
1562
|
+
continue
|
|
1563
|
+
ra = _parse_aware_iso(rec.get("ratified_at"))
|
|
1564
|
+
vf = _parse_aware_iso(rec.get("valid_from"))
|
|
1565
|
+
if ra is not None and vf is not None:
|
|
1566
|
+
stamps.append((ra - vf).total_seconds() / 86400)
|
|
1567
|
+
latencies = sorted(stamps)
|
|
1568
|
+
if latencies:
|
|
1569
|
+
median = latencies[len(latencies) // 2]
|
|
1570
|
+
print(
|
|
1571
|
+
f"time-to-ratify: median {median:.0f} days, max {latencies[-1]:.0f} "
|
|
1572
|
+
f"days ({len(latencies)} stamped record(s))"
|
|
1573
|
+
)
|
|
1574
|
+
except Exception:
|
|
1575
|
+
pass # a stat failure must never cost the health report
|
|
1576
|
+
# Auto-ratification audit (design D5, 2026-09-11): auto share of ever-ratified
|
|
1577
|
+
# records and their later-retired rate vs. human, per kind -- the early-warning
|
|
1578
|
+
# metric for a hallucinating auto-ratify loop. Informational only: never a
|
|
1579
|
+
# finding, never affects the exit code, deliberately absent from --json (key set
|
|
1580
|
+
# pinned, tests/test_cli_doctor.py:118-127). Printed only when at least one
|
|
1581
|
+
# auto: stamp exists anywhere (hot plus archive) -- a manual deployment's output
|
|
1582
|
+
# is otherwise byte-identical (D4: "manual deployments see zero render diff").
|
|
1583
|
+
try:
|
|
1584
|
+
from .doctor import _auto_share_lines
|
|
1585
|
+
|
|
1586
|
+
for line in _auto_share_lines(args.db):
|
|
1587
|
+
print(line)
|
|
1588
|
+
except Exception:
|
|
1589
|
+
pass # a stat failure must never cost the health report
|
|
1590
|
+
if violations or report.findings:
|
|
1591
|
+
print(f"{len(violations)} violation(s), {len(report.findings)} finding(s)")
|
|
1592
|
+
else:
|
|
1593
|
+
print("clean")
|
|
1594
|
+
|
|
1595
|
+
if violations:
|
|
1596
|
+
return 2
|
|
1597
|
+
if args.check and report.findings:
|
|
1598
|
+
return 2
|
|
1599
|
+
return 0
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def viz_main(argv: list[str] | None = None) -> int:
|
|
1603
|
+
"""``sidegraph-viz`` — render a read-only graph of the owned decision/fact store.
|
|
1604
|
+
|
|
1605
|
+
Writes ``<out>.html`` (a self-contained, offline interactive vis-network page) and
|
|
1606
|
+
``<out>.json`` (the same ``{nodes, edges, stats}`` shape). Nodes are decisions, facts, and
|
|
1607
|
+
the entities they anchor to; anchor edges are colored by status (live/degraded/orphaned),
|
|
1608
|
+
plus supersede chains and fact->decision (supports) links. Diagnostic footer counts
|
|
1609
|
+
orphaned/degraded bindings and dangling (unanchored) records.
|
|
1610
|
+
|
|
1611
|
+
``--json`` prints the graph JSON to stdout and writes no file. ``--only-problems`` keeps
|
|
1612
|
+
only the subgraph touching a degraded/orphaned binding or a dangling record.
|
|
1613
|
+
``--no-superseded`` omits terminal-status records (shown dimmed by default). Truncation
|
|
1614
|
+
past ``--max-nodes`` is warned on stderr and recorded in ``stats.truncated`` — never
|
|
1615
|
+
silent. Exit 0 on success (a store WITH problems still exits 0 — the problems are the
|
|
1616
|
+
output), 2 on an operational error (uninitialized store / unwritable output).
|
|
1617
|
+
|
|
1618
|
+
# see design/superpowers/specs/2026-07-12-decision-graph-viz-design.md
|
|
1619
|
+
"""
|
|
1620
|
+
parser = argparse.ArgumentParser(
|
|
1621
|
+
prog="sidegraph-viz",
|
|
1622
|
+
description="Render a read-only interactive graph of the decision/fact store.",
|
|
1623
|
+
)
|
|
1624
|
+
parser.add_argument("--db", default=None, help=_DB_HELP)
|
|
1625
|
+
parser.add_argument(
|
|
1626
|
+
"--out",
|
|
1627
|
+
default="sidegraph-graph",
|
|
1628
|
+
help="output base path; writes <out>.html and <out>.json (default: sidegraph-graph)",
|
|
1629
|
+
)
|
|
1630
|
+
parser.add_argument(
|
|
1631
|
+
"--open",
|
|
1632
|
+
dest="open_browser",
|
|
1633
|
+
action="store_true",
|
|
1634
|
+
help="open the written HTML in the default browser",
|
|
1635
|
+
)
|
|
1636
|
+
parser.add_argument(
|
|
1637
|
+
"--only-problems",
|
|
1638
|
+
action="store_true",
|
|
1639
|
+
help="only the subgraph touching a degraded/orphaned binding or a dangling record",
|
|
1640
|
+
)
|
|
1641
|
+
parser.add_argument(
|
|
1642
|
+
"--no-superseded",
|
|
1643
|
+
dest="include_superseded",
|
|
1644
|
+
action="store_false",
|
|
1645
|
+
help="omit superseded/rejected/deprecated records (default: shown, dimmed)",
|
|
1646
|
+
)
|
|
1647
|
+
parser.add_argument(
|
|
1648
|
+
"--max-nodes",
|
|
1649
|
+
type=int,
|
|
1650
|
+
default=800,
|
|
1651
|
+
help="cap node count; excess is truncated (lowest-priority first) and reported",
|
|
1652
|
+
)
|
|
1653
|
+
parser.add_argument(
|
|
1654
|
+
"--json",
|
|
1655
|
+
action="store_true",
|
|
1656
|
+
help="print the graph JSON to stdout (nothing else) and write no HTML",
|
|
1657
|
+
)
|
|
1658
|
+
args = parser.parse_args(argv)
|
|
1659
|
+
args.db = resolve_store_path(args.db, warn_on_create=False)
|
|
1660
|
+
|
|
1661
|
+
if not _looks_already_initialized(Path(args.db)):
|
|
1662
|
+
print(
|
|
1663
|
+
f"no store at {args.db} — run `sidegraph-init` first",
|
|
1664
|
+
file=sys.stderr,
|
|
1665
|
+
)
|
|
1666
|
+
return 2
|
|
1667
|
+
|
|
1668
|
+
store = Store(args.db)
|
|
1669
|
+
graph = build_graph(
|
|
1670
|
+
store,
|
|
1671
|
+
include_superseded=args.include_superseded,
|
|
1672
|
+
only_problems=args.only_problems,
|
|
1673
|
+
max_nodes=args.max_nodes,
|
|
1674
|
+
)
|
|
1675
|
+
|
|
1676
|
+
if args.json:
|
|
1677
|
+
print(json.dumps(to_json(graph), indent=2))
|
|
1678
|
+
return 0
|
|
1679
|
+
|
|
1680
|
+
out_html = Path(f"{args.out}.html")
|
|
1681
|
+
out_json = Path(f"{args.out}.json")
|
|
1682
|
+
try:
|
|
1683
|
+
out_html.write_text(to_html(graph), encoding="utf-8")
|
|
1684
|
+
out_json.write_text(json.dumps(to_json(graph), indent=2), encoding="utf-8")
|
|
1685
|
+
except OSError as e:
|
|
1686
|
+
print(f"cannot write output ({args.out}): {e}", file=sys.stderr)
|
|
1687
|
+
return 2
|
|
1688
|
+
|
|
1689
|
+
st = graph.stats
|
|
1690
|
+
print(
|
|
1691
|
+
f"wrote {out_html} ({st.decisions} decisions, {st.facts} facts, "
|
|
1692
|
+
f"{st.entities} entities; {st.orphaned_bindings} orphaned, "
|
|
1693
|
+
f"{st.degraded_bindings} degraded, {st.dangling_records} dangling)"
|
|
1694
|
+
)
|
|
1695
|
+
if st.truncated:
|
|
1696
|
+
print(
|
|
1697
|
+
f"warning: truncated {st.truncated} node(s) past --max-nodes={args.max_nodes}",
|
|
1698
|
+
file=sys.stderr,
|
|
1699
|
+
)
|
|
1700
|
+
if args.open_browser:
|
|
1701
|
+
webbrowser.open(out_html.resolve().as_uri())
|
|
1702
|
+
return 0
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
def export_okf_main(argv: list[str] | None = None) -> int:
|
|
1706
|
+
"""``sidegraph-export-okf`` — project the store into an OKF v0.1 bundle (one-way).
|
|
1707
|
+
|
|
1708
|
+
Writes ``--out`` (default ``okf-bundle/``) as an exact snapshot: every Decision and
|
|
1709
|
+
Fact (full append-only history, superseded included), domains collapsed one-per-slug,
|
|
1710
|
+
and the entities decisions anchor to — OKF concept files with YAML frontmatter and
|
|
1711
|
+
bundle-absolute cross-links. Deterministic: same store ⇒ byte-identical bundle. The
|
|
1712
|
+
out dir is only ever cleared when empty or marked ``generator: sidegraph`` in its
|
|
1713
|
+
root ``index.md``; anything else is refused. Exit 0 on success, 2 on an operational
|
|
1714
|
+
error (uninitialized store — never auto-created; refused or unwritable out dir).
|
|
1715
|
+
|
|
1716
|
+
# see design/superpowers/specs/2026-07-23-okf-export-design.md
|
|
1717
|
+
"""
|
|
1718
|
+
parser = argparse.ArgumentParser(
|
|
1719
|
+
prog="sidegraph-export-okf",
|
|
1720
|
+
description="Export the decision store as an OKF v0.1 markdown bundle (one-way).",
|
|
1721
|
+
)
|
|
1722
|
+
parser.add_argument("--db", default=None, help=_DB_HELP)
|
|
1723
|
+
parser.add_argument(
|
|
1724
|
+
"--out", default="okf-bundle", help="bundle output directory (default: okf-bundle)"
|
|
1725
|
+
)
|
|
1726
|
+
args = parser.parse_args(argv)
|
|
1727
|
+
args.db = resolve_store_path(args.db, warn_on_create=False)
|
|
1728
|
+
|
|
1729
|
+
if not _looks_already_initialized(Path(args.db)):
|
|
1730
|
+
print(f"no store at {args.db} — run `sidegraph-init` first", file=sys.stderr)
|
|
1731
|
+
return 2
|
|
1732
|
+
|
|
1733
|
+
bundle = build_bundle(Store(args.db))
|
|
1734
|
+
out_dir = Path(args.out)
|
|
1735
|
+
try:
|
|
1736
|
+
write_bundle(bundle, out_dir)
|
|
1737
|
+
except (OSError, ValueError) as e:
|
|
1738
|
+
print(str(e), file=sys.stderr)
|
|
1739
|
+
return 2
|
|
1740
|
+
|
|
1741
|
+
def _count(section: str) -> int:
|
|
1742
|
+
return sum(1 for p in bundle if p.startswith(f"{section}/") and p != f"{section}/index.md")
|
|
1743
|
+
|
|
1744
|
+
print(
|
|
1745
|
+
f"wrote {out_dir}/ ({_count('decisions')} decisions, {_count('facts')} facts, "
|
|
1746
|
+
f"{_count('domains')} domains, {_count('entities')} entities)"
|
|
1747
|
+
)
|
|
1748
|
+
return 0
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def prepare_commit_msg_main(argv: list[str] | None = None) -> int:
|
|
1752
|
+
"""``sidegraph-prepare-commit-msg`` — git's ``prepare-commit-msg`` hook (design/
|
|
1753
|
+
superpowers/specs/2026-08-07-git-bindings-design.md §1). Comments candidate
|
|
1754
|
+
``Sidegraph-Decision:`` trailers into the commit message template for the human (or
|
|
1755
|
+
agent) to uncomment — never auto-appended (design non-goal: permanent wrong
|
|
1756
|
+
attribution in an immutable trailer is the failure mode this gates against).
|
|
1757
|
+
|
|
1758
|
+
Argument contract (review M6, measured): git invokes a prepare-commit-msg hook as
|
|
1759
|
+
``<message-file> [<source> [<sha1>]]`` — a plain ``git commit`` passes exactly ONE
|
|
1760
|
+
arg (source absent). This hook acts only when ``source`` is absent or
|
|
1761
|
+
``"template"``; every other source (``message``/``merge``/``squash``/``commit`` —
|
|
1762
|
+
amend) leaves the message file untouched, and any malformed invocation (no args at
|
|
1763
|
+
all) is also a no-op.
|
|
1764
|
+
|
|
1765
|
+
Never blocks and never stalls (§0): every internal error, and the mechanism's own
|
|
1766
|
+
2s wall-clock budget (:data:`sidegraph.gitio.HOOK_WALL_CLOCK_BUDGET_SECONDS`), degrade
|
|
1767
|
+
to writing nothing — this function ALWAYS returns 0. A ``git commit`` must never fail
|
|
1768
|
+
or hang because this hook did.
|
|
1769
|
+
"""
|
|
1770
|
+
argv = list(sys.argv[1:]) if argv is None else list(argv)
|
|
1771
|
+
if not argv:
|
|
1772
|
+
return 0
|
|
1773
|
+
message_file = argv[0]
|
|
1774
|
+
source = argv[1] if len(argv) > 1 else None
|
|
1775
|
+
if source not in (None, "", "template"):
|
|
1776
|
+
return 0
|
|
1777
|
+
try:
|
|
1778
|
+
cwd = Path.cwd()
|
|
1779
|
+
store_dir = Path(resolve_store_path(None, warn_on_create=False))
|
|
1780
|
+
gitio.apply_prepare_commit_msg(message_file, cwd, store_dir)
|
|
1781
|
+
except Exception:
|
|
1782
|
+
# Never blocks and never stalls (§0/§1 step 3) — an internal error here must
|
|
1783
|
+
# never fail (or even warn during) the surrounding `git commit`.
|
|
1784
|
+
pass
|
|
1785
|
+
return 0
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def _format_blame_record(rec: dict) -> str:
|
|
1789
|
+
if not rec["resolved"]:
|
|
1790
|
+
return f"{rec['id']} (unresolved)"
|
|
1791
|
+
tag = f"superseded by {rec['superseded_by']}" if rec["superseded_by"] else rec["kind"]
|
|
1792
|
+
return f"{rec['id']} ({tag}): {rec['label']}"
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def blame_main(argv: list[str] | None = None) -> int:
|
|
1796
|
+
"""``sidegraph-blame`` — derived line-level "why" (design §2). ``git blame`` joined
|
|
1797
|
+
to the decisions/facts each hunk's commit carries, via TWO paths (deduped): commit
|
|
1798
|
+
trailers (``Sidegraph-Decision:``) and ``provenance.commit`` (post-П0, decisions AND
|
|
1799
|
+
facts). A record resolved via an old sha that is now superseded prints its
|
|
1800
|
+
``superseded_by`` pointer; an unresolved ULID is listed as unresolved, never guessed.
|
|
1801
|
+
|
|
1802
|
+
Read-only over records (§0): opens the store's index read-only (never a writable
|
|
1803
|
+
``Store()``); a missing/locked index degrades every hunk to unresolved records
|
|
1804
|
+
rather than failing the command — blame without a store is still useful blame.
|
|
1805
|
+
|
|
1806
|
+
Output capped at 50 hunk rows / 6000 chars, whichever comes first (review M8); the
|
|
1807
|
+
last line states the cap and what was omitted — never a silent truncation.
|
|
1808
|
+
|
|
1809
|
+
Exit codes: 0 on success (JSON or table printed), 1 on an operational git failure
|
|
1810
|
+
(not a git repo, unknown file/range, git not installed — G9's CLI error surface;
|
|
1811
|
+
unlike the hook, an explicit user command reports this instead of degrading).
|
|
1812
|
+
"""
|
|
1813
|
+
parser = argparse.ArgumentParser(
|
|
1814
|
+
prog="sidegraph-blame",
|
|
1815
|
+
description="git blame a file, joined to the decisions/facts each hunk's commit "
|
|
1816
|
+
"carries (commit trailers + provenance.commit).",
|
|
1817
|
+
)
|
|
1818
|
+
parser.add_argument("file", help="path to blame, relative to the current directory")
|
|
1819
|
+
parser.add_argument("--range", default=None, metavar="A,B", help="line range, e.g. 10,42")
|
|
1820
|
+
parser.add_argument("--db", default=None, help=_DB_HELP)
|
|
1821
|
+
parser.add_argument("--json", action="store_true", help="print the result as JSON")
|
|
1822
|
+
args = parser.parse_args(argv)
|
|
1823
|
+
|
|
1824
|
+
line_range = None
|
|
1825
|
+
if args.range:
|
|
1826
|
+
parts = args.range.split(",")
|
|
1827
|
+
if len(parts) != 2 or not all(p.strip().lstrip("-").isdigit() for p in parts):
|
|
1828
|
+
print(f"sidegraph-blame: invalid --range {args.range!r}, expected A,B", file=sys.stderr)
|
|
1829
|
+
return 1
|
|
1830
|
+
line_range = (int(parts[0]), int(parts[1]))
|
|
1831
|
+
|
|
1832
|
+
cwd = Path.cwd()
|
|
1833
|
+
db_path = Path(resolve_store_path(args.db, warn_on_create=False))
|
|
1834
|
+
hunks = gitio.blame_report(args.file, cwd, db_path, line_range)
|
|
1835
|
+
if hunks is None:
|
|
1836
|
+
print(
|
|
1837
|
+
f"sidegraph-blame: git blame failed for {args.file!r} (not a git repo, unknown "
|
|
1838
|
+
"file/range, or git not installed)",
|
|
1839
|
+
file=sys.stderr,
|
|
1840
|
+
)
|
|
1841
|
+
return 1
|
|
1842
|
+
|
|
1843
|
+
rows: list[dict] = [
|
|
1844
|
+
{
|
|
1845
|
+
"start": h.start,
|
|
1846
|
+
"end": h.end,
|
|
1847
|
+
"sha": h.sha,
|
|
1848
|
+
"date": h.date,
|
|
1849
|
+
"records": [
|
|
1850
|
+
{
|
|
1851
|
+
"id": r.id,
|
|
1852
|
+
"kind": r.kind,
|
|
1853
|
+
"label": r.label,
|
|
1854
|
+
"resolved": r.resolved,
|
|
1855
|
+
"superseded_by": r.superseded_by,
|
|
1856
|
+
}
|
|
1857
|
+
for r in h.records
|
|
1858
|
+
],
|
|
1859
|
+
}
|
|
1860
|
+
for h in hunks
|
|
1861
|
+
]
|
|
1862
|
+
|
|
1863
|
+
# Output cap (review M8): 50 hunk rows / 6000 chars, whichever first — applied
|
|
1864
|
+
# identically to both the human table and --json below, so neither surface silently
|
|
1865
|
+
# exceeds it.
|
|
1866
|
+
capped: list[dict] = []
|
|
1867
|
+
total_chars = 0
|
|
1868
|
+
omitted = 0
|
|
1869
|
+
for row in rows:
|
|
1870
|
+
row_chars = len(json.dumps(row))
|
|
1871
|
+
if len(capped) >= gitio.BLAME_ROW_CAP or total_chars + row_chars > gitio.BLAME_CHAR_CAP:
|
|
1872
|
+
omitted = len(rows) - len(capped)
|
|
1873
|
+
break
|
|
1874
|
+
capped.append(row)
|
|
1875
|
+
total_chars += row_chars
|
|
1876
|
+
|
|
1877
|
+
if args.json:
|
|
1878
|
+
cap_info = {"rows": gitio.BLAME_ROW_CAP, "chars": gitio.BLAME_CHAR_CAP} if omitted else None
|
|
1879
|
+
print(
|
|
1880
|
+
json.dumps(
|
|
1881
|
+
{"file": args.file, "hunks": capped, "omitted": omitted, "cap": cap_info},
|
|
1882
|
+
indent=2,
|
|
1883
|
+
)
|
|
1884
|
+
)
|
|
1885
|
+
else:
|
|
1886
|
+
print(args.file)
|
|
1887
|
+
for row in capped:
|
|
1888
|
+
records_str = (
|
|
1889
|
+
"; ".join(_format_blame_record(r) for r in row["records"])
|
|
1890
|
+
if row["records"]
|
|
1891
|
+
else "(no decision/fact recorded)"
|
|
1892
|
+
)
|
|
1893
|
+
line_str = (
|
|
1894
|
+
f"{row['start']}" if row["start"] == row["end"] else f"{row['start']}-{row['end']}"
|
|
1895
|
+
)
|
|
1896
|
+
print(f" {line_str}\t{row['sha'][:12]}\t{row['date'] or '?'}\t{records_str}")
|
|
1897
|
+
if omitted:
|
|
1898
|
+
print(
|
|
1899
|
+
f"... {omitted} more hunk row(s) omitted "
|
|
1900
|
+
f"(cap: {gitio.BLAME_ROW_CAP} rows / {gitio.BLAME_CHAR_CAP} chars)"
|
|
1901
|
+
)
|
|
1902
|
+
return 0
|