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/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