brainiac-cli 0.16.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.
- brain/__init__.py +39 -0
- brain/__main__.py +14 -0
- brain/_assets/AGENTS.md +564 -0
- brain/_assets/overlay/template/brand/brand-guide.md +24 -0
- brain/_assets/overlay/template/keywords/glossary.md +15 -0
- brain/_assets/overlay/template/people/roster.md +14 -0
- brain/_assets/overlay/template/voice/voice-profile.md +34 -0
- brain/_assets/routines/manifest.json +257 -0
- brain/_assets/scripts/brain-brief-mac.plist +59 -0
- brain/_assets/scripts/brain-brief.sh +74 -0
- brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
- brain/_assets/scripts/brain-synthesis.sh +214 -0
- brain/_assets/scripts/install-brief-mac.sh +152 -0
- brain/_assets/scripts/install-brief-windows.ps1 +97 -0
- brain/_assets/scripts/register_tasks.py +386 -0
- brain/_assets/templates/company.md +21 -0
- brain/_assets/templates/concept.md +27 -0
- brain/_assets/templates/daily.md +20 -0
- brain/_assets/templates/decision.md +42 -0
- brain/_assets/templates/meeting.md +33 -0
- brain/_assets/templates/person.md +20 -0
- brain/_assets/templates/project.md +23 -0
- brain/_assets/templates/state-moc.md +40 -0
- brain/_version.py +12 -0
- brain/anchor.py +121 -0
- brain/audit.py +422 -0
- brain/backup.py +210 -0
- brain/brief.py +417 -0
- brain/capture.py +117 -0
- brain/chunk.py +249 -0
- brain/classification.py +134 -0
- brain/cli.py +1906 -0
- brain/config.py +368 -0
- brain/connect.py +362 -0
- brain/context.py +108 -0
- brain/core.py +3018 -0
- brain/doctor.py +1161 -0
- brain/egress.py +148 -0
- brain/embed.py +857 -0
- brain/encryption.py +217 -0
- brain/frontmatter.py +102 -0
- brain/golden_probe.py +678 -0
- brain/graph.py +369 -0
- brain/graphify.py +352 -0
- brain/index.py +1576 -0
- brain/ingest/__init__.py +19 -0
- brain/ingest/handlers/__init__.py +43 -0
- brain/ingest/handlers/base.py +95 -0
- brain/ingest/handlers/docx.py +78 -0
- brain/ingest/handlers/email.py +228 -0
- brain/ingest/handlers/html.py +142 -0
- brain/ingest/handlers/image.py +91 -0
- brain/ingest/handlers/pdf.py +99 -0
- brain/ingest/handlers/pptx.py +69 -0
- brain/ingest/handlers/tables.py +41 -0
- brain/ingest/handlers/text.py +43 -0
- brain/ingest/handlers/xlsx.py +100 -0
- brain/ingest/handlers/zip.py +163 -0
- brain/ingest/pipeline.py +839 -0
- brain/ingest/transcript.py +158 -0
- brain/init.py +870 -0
- brain/maintenance.py +2266 -0
- brain/mcp_adapter.py +217 -0
- brain/multihop.py +232 -0
- brain/notes.py +195 -0
- brain/overlay.py +183 -0
- brain/projection.py +79 -0
- brain/rerank.py +425 -0
- brain/snapshot.py +231 -0
- brain/update.py +743 -0
- brain/vectors.py +225 -0
- brainiac_cli-0.16.0.dist-info/METADATA +306 -0
- brainiac_cli-0.16.0.dist-info/RECORD +77 -0
- brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
- brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
- brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
brain/cli.py
ADDED
|
@@ -0,0 +1,1906 @@
|
|
|
1
|
+
"""`brain` — the one universal interface any tool/harness can call.
|
|
2
|
+
|
|
3
|
+
THIS is the integration surface (not BrainCore, not MCP). It returns sourced
|
|
4
|
+
results as JSON and applies the deny-by-default classification filter as the
|
|
5
|
+
FINAL stage before stdout. A harness self-discovers the whole contract from
|
|
6
|
+
`brain --help` after reading one paragraph in AGENTS.md.
|
|
7
|
+
|
|
8
|
+
brain init --validate-overlay [--overlay-dir DIR] # PER-02: validate the
|
|
9
|
+
# per-user overlay/{voice,brand,
|
|
10
|
+
# keywords,people}/ layer (minimal
|
|
11
|
+
# slice; full init lands later)
|
|
12
|
+
brain search <query> [--json] [-k N] [--rerank] [--max-tier TIER]
|
|
13
|
+
brain hybrid-search <query> ... # alias of search (fused RRF BM25+dense)
|
|
14
|
+
brain grep <pattern> [--regex] [-k N] # lexical-first, NO embedding
|
|
15
|
+
brain bases-query --where k=v [-k N] # structured frontmatter view, NO embedding
|
|
16
|
+
brain bases-query --latest-only # TMP-02: exclude superseded notes
|
|
17
|
+
brain bases-query --as-of YYYY-MM-DD # TMP-02: point-in-time view
|
|
18
|
+
brain supersede <old-id> <new-id> [--reason R] # retire old-id -> new-id [HOST]
|
|
19
|
+
brain graph-expand <id...> [--depth D] # wikilink-BFS + PPR, DISCOVERY-ONLY
|
|
20
|
+
brain graphify [--force] [--dry-run] # monthly discovery graph build [HOST]
|
|
21
|
+
brain get <id> [--json] [--max-tier TIER]
|
|
22
|
+
brain read <id> # alias of get
|
|
23
|
+
brain recent [--json] [-n N] [--max-tier TIER]
|
|
24
|
+
brain draft-capture [--id ID] [--source] # VM-side capture: stage a DRAFT
|
|
25
|
+
brain status [--json] # snapshot gen/age + pending drafts
|
|
26
|
+
brain doctor [--json] # health + version table, ALL surfaces (read-only)
|
|
27
|
+
brain sync [--publish] # incremental upsert + drain drafts [HOST]
|
|
28
|
+
brain snapshot [--dest DIR] # publish read-only snapshot [HOST]
|
|
29
|
+
brain rebuild [--vault DIR] # rebuild the derived index (safe)
|
|
30
|
+
brain project --dest DIR [--max-tier TIER] # real containment: filtered copy
|
|
31
|
+
brain ingest [--dry-run] # host-broker: drain <vault>/inbox/ (ING-01/03)
|
|
32
|
+
brain ingest-transcript <path> --origin O [--language L] # host-broker (ING-04)
|
|
33
|
+
brain write <relpath> [--reason R] # host-broker, audited, fails closed
|
|
34
|
+
brain verify-audit [--json] # verify the Ed25519 chain
|
|
35
|
+
brain connect --client <c> [--remove] # SUI-02, host-broker: wire/unwire ONE
|
|
36
|
+
# client (claude-code|claude-desktop|
|
|
37
|
+
# codex|gemini) — diff-first, asks
|
|
38
|
+
# before touching any user config file
|
|
39
|
+
brain mcp-config [--json] # PRINT-ONLY equivalent for the
|
|
40
|
+
# claude-desktop MCP stanza (paste it
|
|
41
|
+
# yourself instead of `connect` writing it)
|
|
42
|
+
|
|
43
|
+
Trust role (--role / $BRAIN_ROLE, default host): the Cowork Linux VM runs
|
|
44
|
+
``--role vm`` — a READ + DRAFT surface. It may run the read tools + ``status`` +
|
|
45
|
+
``draft-capture`` ONLY; the [HOST] commands (write/rebuild/sync/snapshot/project/
|
|
46
|
+
verify-audit) are refused on the VM. The VM opens only the read-only published
|
|
47
|
+
snapshot (never WAL) and never resolves a signing key. See AGENTS.md §6.
|
|
48
|
+
|
|
49
|
+
Egress: results are filtered to ``--max-tier``. Default on the trusted host:
|
|
50
|
+
the FULL vault (MNPI) — narrow with ``--max-tier`` or ``$BRAIN_DEFAULT_MAX_TIER``.
|
|
51
|
+
Default on ``--role vm``: Internal — the untrusted leg keeps the conservative
|
|
52
|
+
cap, and elevating it is the explicit human gate. Unlabelled or unrecognised
|
|
53
|
+
notes rank as MNPI (default-deny at any cap below MNPI). The same filter is
|
|
54
|
+
reused by the optional MCP adapter (a thin wrapper over this).
|
|
55
|
+
"""
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import argparse
|
|
59
|
+
import json
|
|
60
|
+
import sys
|
|
61
|
+
from typing import Any
|
|
62
|
+
|
|
63
|
+
from . import __version__, classification as cls
|
|
64
|
+
from . import connect as _connect
|
|
65
|
+
from . import egress
|
|
66
|
+
from .core import BrainCore
|
|
67
|
+
|
|
68
|
+
EPILOG = """\
|
|
69
|
+
note: --vault (and $BRAIN_VAULT) is a TOP-LEVEL option — it must come BEFORE the
|
|
70
|
+
subcommand. `brain --vault ./vault rebuild`, not `brain rebuild --vault …`.
|
|
71
|
+
With $BRAIN_VAULT set, you can omit it entirely.
|
|
72
|
+
|
|
73
|
+
agentic tool surface (RET-04 — compose these; lexical-first, embed lazily):
|
|
74
|
+
grep / bases-query never embed (cheap first probe); hybrid-search embeds the
|
|
75
|
+
query only on semantic escalation; graph-expand is DISCOVERY-ONLY (its derived
|
|
76
|
+
wikilink graph is never authoritative — confirm candidates with get/read).
|
|
77
|
+
|
|
78
|
+
temporal-intent routing (TMP-03): when a question is really about TIME —
|
|
79
|
+
"latest", "current version", "as of <date>", "previous version" — probe the
|
|
80
|
+
temporal query surface FIRST, before plain semantic search:
|
|
81
|
+
brain bases-query --latest-only --json # "what's current" / "latest"
|
|
82
|
+
brain bases-query --as-of 2026-03-01 --json # "as of <date>" / point-in-time
|
|
83
|
+
brain get <id> --json # inspect previous_version /
|
|
84
|
+
# superseded_by / is_latest_version
|
|
85
|
+
# on any single hit ("previous version")
|
|
86
|
+
search/get results also carry `is_latest_version` on every hit, so even a plain
|
|
87
|
+
semantic-search agent can prefer the current claim without a second round-trip.
|
|
88
|
+
|
|
89
|
+
retrieval discipline (non-negotiable; details in AGENTS.md §5):
|
|
90
|
+
- every search hit carries `type` — the AUTHORITY signal. A `type: decision`
|
|
91
|
+
hit IS the recorded decision layer; a `type: source` hit (memos, decks,
|
|
92
|
+
drafts) is material under consideration and NEVER overturns a decision on
|
|
93
|
+
its own. Conflict between a newer source and a decision note? Report the
|
|
94
|
+
tension — never promote the proposal.
|
|
95
|
+
- decision-state questions ("what have we decided", "latest decisions",
|
|
96
|
+
"current state of X"): `brain dossier "<question>" --json` is the
|
|
97
|
+
ONE-CALL sweep — decision layer + sources + tensions (newer sources
|
|
98
|
+
post-dating a decision) + freshness, retired versions pre-excluded.
|
|
99
|
+
(`bases-query --where type=decision --latest-only` remains the raw
|
|
100
|
+
probe.) The newest DOCUMENT VERSION is not the newest DECISION STATE.
|
|
101
|
+
- react to the search response's `freshness` block ("N sources newer than
|
|
102
|
+
your newest hit"): probe past your hits (recent / --latest-only / a
|
|
103
|
+
narrower search) before answering a "latest/current" question.
|
|
104
|
+
- a `-- N withheld` egress line means elevate --max-tier, not "vault empty".
|
|
105
|
+
|
|
106
|
+
examples:
|
|
107
|
+
brain grep "sqlite-vec" --json
|
|
108
|
+
brain bases-query --where type=note --where classification=Internal --json
|
|
109
|
+
brain bases-query --latest-only --where type=note --json
|
|
110
|
+
brain bases-query --as-of 2026-03-01 --json
|
|
111
|
+
brain search "arctic embed" --rerank --json
|
|
112
|
+
brain graph-expand brain-engine --depth 2 --json
|
|
113
|
+
brain get arctic-embed-choice --json
|
|
114
|
+
brain recent -n 5 --max-tier Confidential
|
|
115
|
+
brain --vault ./vault rebuild
|
|
116
|
+
brain --vault ./vault supersede arctic-embed-choice e5-small-choice --reason "switched embedder"
|
|
117
|
+
brain --vault ./vault project --dest /tmp/vm-workspace --max-tier Internal
|
|
118
|
+
|
|
119
|
+
egress filter (deny-by-default below the cap):
|
|
120
|
+
tiers low->high: Public < Internal < Confidential < Restricted < MNPI
|
|
121
|
+
default --max-tier: full vault (MNPI) on host, Internal on --role vm;
|
|
122
|
+
unlabelled notes rank as MNPI (withheld at any lower cap).
|
|
123
|
+
the filter is the final stage before stdout. it is an egress DECISION, not
|
|
124
|
+
containment — a file-capable harness reads Markdown directly; use
|
|
125
|
+
`brain project` (a filtered workspace copy) for real containment.
|
|
126
|
+
JSON `egress.total` INCLUDES withheld notes by design (it is an audit count,
|
|
127
|
+
not a leak of content); `egress.surfaced` is what was printed.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _json_default(o: Any) -> Any:
|
|
132
|
+
"""Coerce non-JSON-native values to native types for ``json.dump``.
|
|
133
|
+
|
|
134
|
+
The dense-retrieval path (``OnnxEmbedder``/near-dup scoring) hands back
|
|
135
|
+
``numpy`` scalars/arrays despite the ``list[list[float]]`` type contract, and
|
|
136
|
+
stdlib ``json`` cannot serialise ``numpy.float32`` etc. — that crashed
|
|
137
|
+
``brain integrity --json`` (and would crash ANY ``--json`` subcommand) on the
|
|
138
|
+
first real hit (S11-BUG-01). Duck-typed so no hard ``numpy`` import is needed:
|
|
139
|
+
numpy scalars expose ``.item()`` (→ a native Python scalar), arrays expose
|
|
140
|
+
``.tolist()``. Sets/tuples degrade to lists. Anything else falls through to
|
|
141
|
+
``str`` rather than re-raising, so emission never crashes on an odd type."""
|
|
142
|
+
item = getattr(o, "item", None)
|
|
143
|
+
if callable(item):
|
|
144
|
+
try:
|
|
145
|
+
return o.item()
|
|
146
|
+
except (ValueError, TypeError):
|
|
147
|
+
pass
|
|
148
|
+
tolist = getattr(o, "tolist", None)
|
|
149
|
+
if callable(tolist):
|
|
150
|
+
try:
|
|
151
|
+
return o.tolist()
|
|
152
|
+
except (ValueError, TypeError):
|
|
153
|
+
pass
|
|
154
|
+
if isinstance(o, (set, frozenset, tuple)):
|
|
155
|
+
return list(o)
|
|
156
|
+
return str(o)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _emit(obj: Any, as_json: bool, human: str | None = None) -> None:
|
|
160
|
+
if as_json:
|
|
161
|
+
json.dump(obj, sys.stdout, ensure_ascii=False, indent=2, default=_json_default)
|
|
162
|
+
sys.stdout.write("\n")
|
|
163
|
+
else:
|
|
164
|
+
sys.stdout.write((human if human is not None else str(obj)) + "\n")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _filter_dicts(items: list[dict], max_tier: str) -> tuple[list[dict], dict]:
|
|
168
|
+
# THE single egress chokepoint — every content-returning subcommand routes
|
|
169
|
+
# through egress.apply_gate so a new content path cannot silently bypass the
|
|
170
|
+
# deny-by-default gate (SEC-01, r2-codex). The MCP adapter shares it too.
|
|
171
|
+
surfaced, report = egress.apply_gate(items, max_tier)
|
|
172
|
+
# Actionable-elevation nudge (RET-08): a starved result at the default
|
|
173
|
+
# Internal cap reads to the agent as "the vault is empty" and drives it to
|
|
174
|
+
# web search — leaking internal topics outward. Say WHY it's thin and HOW to
|
|
175
|
+
# elevate, in the report dict so it surfaces in BOTH --json (agent-facing)
|
|
176
|
+
# and the text footer. The tier stays the human gate; this only signposts it.
|
|
177
|
+
if report.get("withheld", 0) > 0 and max_tier != cls.TIERS[-1]:
|
|
178
|
+
report["hint"] = (
|
|
179
|
+
f"{report['withheld']} note(s) withheld above the {max_tier} cap — "
|
|
180
|
+
f"re-run with --max-tier Restricted (or MNPI for the most sensitive) "
|
|
181
|
+
f"to include them, rather than treating the vault as empty."
|
|
182
|
+
)
|
|
183
|
+
return surfaced, report
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _freshness_block(core: Any, surfaced: list[dict], max_tier: str) -> dict | None:
|
|
187
|
+
"""RET-09: the "the vault continues past your hits" signal. Computed from
|
|
188
|
+
the surfaced hits' valid-time dates; None when no hit carries a date (a
|
|
189
|
+
hitless or dateless result has nothing to compare against). The hint only
|
|
190
|
+
renders when newer material actually exists — an agent answering a
|
|
191
|
+
"latest/current" question must probe past its hits before declaring the
|
|
192
|
+
answer current (this is the exact failure of the 2026-07 G&P benchmark:
|
|
193
|
+
a coherent-but-stale curated answer with newer sources sitting in raw/)."""
|
|
194
|
+
dates = [h.get("date", "") for h in surfaced if h.get("date")]
|
|
195
|
+
if not dates:
|
|
196
|
+
return None
|
|
197
|
+
try:
|
|
198
|
+
fresh = core.source_freshness(max(dates), max_tier)
|
|
199
|
+
except Exception: # noqa: BLE001 — a freshness probe must never break search
|
|
200
|
+
return None
|
|
201
|
+
if fresh.get("newer_count", 0) > 0:
|
|
202
|
+
fresh["hint"] = (
|
|
203
|
+
f"{fresh['newer_count']} note(s)/source(s) carry dates newer than your "
|
|
204
|
+
f"newest hit ({fresh['newest_hit_date']}; vault newest "
|
|
205
|
+
f"{fresh['vault_newest']}). For 'latest/current' questions, probe past "
|
|
206
|
+
f"these hits (brain recent, bases-query --latest-only, or a narrower "
|
|
207
|
+
f"search) before treating this result as the current state."
|
|
208
|
+
)
|
|
209
|
+
return fresh
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _egress_footer(report: dict) -> str:
|
|
213
|
+
"""The `-- N/M surfaced; K withheld` line, plus the elevation hint when the
|
|
214
|
+
gate withheld anything (RET-08). One renderer so every read surface nudges
|
|
215
|
+
identically."""
|
|
216
|
+
line = (f"-- {report['surfaced']}/{report['total']} surfaced; "
|
|
217
|
+
f"{report['withheld']} withheld (max-tier={report['max_tier']})")
|
|
218
|
+
if report.get("hint"):
|
|
219
|
+
line += f"\n-- {report['hint']}"
|
|
220
|
+
return line
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
224
|
+
p = argparse.ArgumentParser(
|
|
225
|
+
prog="brain",
|
|
226
|
+
description="Local any-LLM second brain — search/get/recent over Markdown, "
|
|
227
|
+
"sourced JSON out, deny-by-default classification filter.",
|
|
228
|
+
epilog=EPILOG,
|
|
229
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
230
|
+
)
|
|
231
|
+
p.add_argument("--version", action="version", version=f"brain {__version__}")
|
|
232
|
+
p.add_argument("--vault", default=None, help="vault root (default: $BRAIN_VAULT or ./vault)")
|
|
233
|
+
p.add_argument(
|
|
234
|
+
"--role", default=None, choices=("host", "vm"),
|
|
235
|
+
help="trust role (default: $BRAIN_ROLE or host). 'vm' = read+draft only: "
|
|
236
|
+
"the host-broker commands are refused and the index opens read-only.",
|
|
237
|
+
)
|
|
238
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
239
|
+
|
|
240
|
+
def add_common(sp: argparse.ArgumentParser) -> None:
|
|
241
|
+
sp.add_argument("--json", action="store_true", help="emit JSON")
|
|
242
|
+
sp.add_argument(
|
|
243
|
+
"--max-tier", default=None, choices=cls.TIERS,
|
|
244
|
+
help="egress cap; results above this tier are withheld "
|
|
245
|
+
f"(default: {cls.DEFAULT_MAX_TIER} on host, "
|
|
246
|
+
f"{cls.VM_DEFAULT_MAX_TIER} on --role vm)",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def add_search(name: str, help_text: str) -> None:
|
|
250
|
+
sp = sub.add_parser(name, help=help_text)
|
|
251
|
+
sp.add_argument("query")
|
|
252
|
+
sp.add_argument("-k", type=int, default=10, help="max results (default: 10)")
|
|
253
|
+
sp.add_argument("--rerank", action="store_true",
|
|
254
|
+
help="re-order the top results with the cross-encoder (RET-02); "
|
|
255
|
+
"skippable — off by default, degrades to no-op if absent")
|
|
256
|
+
sp.add_argument("--rerank-top", type=int, default=15,
|
|
257
|
+
help="rerank window, clamped to 10-20 (default: 15)")
|
|
258
|
+
sp.add_argument("--rrf-k", type=int, default=60,
|
|
259
|
+
help="Reciprocal Rank Fusion constant (default: 60)")
|
|
260
|
+
add_common(sp)
|
|
261
|
+
|
|
262
|
+
# -- setup (PER-02 / INS-02) — `brain init` ---------------------------
|
|
263
|
+
# Filesystem + subprocess only: never opens the index, never constructs
|
|
264
|
+
# BrainCore (a brand-new install has no index yet). Two modes:
|
|
265
|
+
# --validate-overlay : the minimal PER-02 shape check (unchanged).
|
|
266
|
+
# --full : INS-02 full first-run orchestration (detect client,
|
|
267
|
+
# scaffold+validate overlay, drive task registration).
|
|
268
|
+
sp = sub.add_parser(
|
|
269
|
+
"init",
|
|
270
|
+
help="first-run setup: --validate-overlay (PER-02 shape check) or "
|
|
271
|
+
"--full (INS-02 install orchestration: overlay + task registration)",
|
|
272
|
+
)
|
|
273
|
+
sp.add_argument("--validate-overlay", action="store_true",
|
|
274
|
+
help="validate the per-user overlay/{voice,brand,keywords,people}/ layer")
|
|
275
|
+
sp.add_argument("--full", action="store_true",
|
|
276
|
+
help="full first-run orchestration: detect client, scaffold+validate "
|
|
277
|
+
"the overlay, and drive per-client scheduled-task registration "
|
|
278
|
+
"(host = launchd/Task Scheduler directly; Cowork/VM = paste-prompt)")
|
|
279
|
+
sp.add_argument("--overlay-dir", default=None,
|
|
280
|
+
help="overlay dir override (default: $BRAIN_OVERLAY_DIR or <vault>/overlay)")
|
|
281
|
+
sp.add_argument("--no-scaffold-overlay", dest="scaffold_overlay", action="store_false",
|
|
282
|
+
help="[--full] do NOT scaffold empty overlay categories from the template")
|
|
283
|
+
sp.add_argument("--template-dir", default=None,
|
|
284
|
+
help="[--full] overlay template dir (default: <repo>/overlay/template)")
|
|
285
|
+
sp.add_argument("--no-register-tasks", dest="register_tasks", action="store_false",
|
|
286
|
+
help="[--full] skip the per-client scheduled-task registration step")
|
|
287
|
+
sp.add_argument("--apply", action="store_true",
|
|
288
|
+
help="[--full, host only] actually invoke the OS installer script "
|
|
289
|
+
"(default: dry-run read-only probe). Ignored on the VM leg.")
|
|
290
|
+
sp.add_argument("--manifest", default=None,
|
|
291
|
+
help="[--full] task manifest path (default: installed/repo routines/manifest.json)")
|
|
292
|
+
sp.add_argument("--save-cowork-prompt", default=None,
|
|
293
|
+
help="[--full, cowork] also write the Cowork paste-prompt to this file")
|
|
294
|
+
sp.add_argument("--no-seed-vault", dest="seed_vault", action="store_false",
|
|
295
|
+
help="[--full] do NOT seed a genuinely empty vault with the 3 "
|
|
296
|
+
"generic sample notes")
|
|
297
|
+
sp.add_argument("--import-from", default=None,
|
|
298
|
+
help="[--full, host only] guided first-ingest: stage an existing "
|
|
299
|
+
"folder of documents (e.g. an Obsidian vault) into this "
|
|
300
|
+
"vault's inbox/ and run the standard ingest drain. Prints a "
|
|
301
|
+
"dry-run manifest (file count/bytes/extensions) first; pass "
|
|
302
|
+
"--yes to actually stage + ingest. Refused on --role vm.")
|
|
303
|
+
sp.add_argument("--yes", action="store_true",
|
|
304
|
+
help="[--import-from] skip the interactive y/N confirmation")
|
|
305
|
+
sp.add_argument("--import-force", action="store_true",
|
|
306
|
+
help="[--import-from] override the default safety caps "
|
|
307
|
+
"(5000 files / 500 MB)")
|
|
308
|
+
sp.add_argument("--json", action="store_true")
|
|
309
|
+
|
|
310
|
+
sp = sub.add_parser(
|
|
311
|
+
"doctor",
|
|
312
|
+
help="READ-ONLY health + version table across every surface: engine, "
|
|
313
|
+
"index/snapshot schema, CLI + Desktop plugin stores, staged "
|
|
314
|
+
"workspaces, marketplace cache freshness (ADR-0005 Ruling 2). "
|
|
315
|
+
"role=vm gets the staged-workspace-only subset (engine stamp, skill "
|
|
316
|
+
"bundles, snapshot, model cache, maintain heartbeat) plus a "
|
|
317
|
+
"host-only-surfaces list, instead of crashing or host checks",
|
|
318
|
+
)
|
|
319
|
+
sp.add_argument("--json", action="store_true")
|
|
320
|
+
sp.add_argument("--check-registry", action="store_true",
|
|
321
|
+
help="host only: add the 'PyPI registry drift' row (repo tag / "
|
|
322
|
+
"installed / latest-published-on-PyPI) via a single cached "
|
|
323
|
+
"HTTPS metadata read. Off by default — this is the only "
|
|
324
|
+
"network call `doctor` ever makes, and only with this flag.")
|
|
325
|
+
|
|
326
|
+
sp = sub.add_parser(
|
|
327
|
+
"mcp-config",
|
|
328
|
+
help="print the MCP-client config entry to run brain-mcp against this "
|
|
329
|
+
"vault (paste into Claude Desktop / Claude Code mcpServers). "
|
|
330
|
+
"Read-only; no index or key touched.",
|
|
331
|
+
)
|
|
332
|
+
sp.add_argument("--name", default="brainiac",
|
|
333
|
+
help="server name/key in the config (default: %(default)s) — "
|
|
334
|
+
"use a distinct name per vault")
|
|
335
|
+
sp.add_argument("--max-tier", default="MNPI",
|
|
336
|
+
help="egress ceiling for this MCP server (default: %(default)s "
|
|
337
|
+
"= full vault, matching the host CLI default; narrow to "
|
|
338
|
+
"e.g. Internal for a server that must stay capped)")
|
|
339
|
+
sp.add_argument("--json", action="store_true")
|
|
340
|
+
|
|
341
|
+
sp = sub.add_parser(
|
|
342
|
+
"connect",
|
|
343
|
+
help="SUI-02: wire ONE client (claude-code|claude-desktop|codex|gemini) to "
|
|
344
|
+
"this vault — shows a diff, asks before touching any user config file, "
|
|
345
|
+
"idempotent (re-run says 'already connected'). Host-only, "
|
|
346
|
+
"self-executing (not print-only — that's `mcp-config`). "
|
|
347
|
+
"`--remove` unwires the same client.",
|
|
348
|
+
)
|
|
349
|
+
sp.add_argument("--client", required=True, choices=list(_connect.CLIENTS))
|
|
350
|
+
sp.add_argument("--target", default=".",
|
|
351
|
+
help="project directory being wired (default: cwd) — where "
|
|
352
|
+
"CLAUDE.md/AGENTS.md/.gemini/settings.json live")
|
|
353
|
+
sp.add_argument("--name", default="brainiac",
|
|
354
|
+
help="MCP server name for --client claude-desktop (default: %(default)s)")
|
|
355
|
+
sp.add_argument("--max-tier", default="MNPI",
|
|
356
|
+
help="egress ceiling baked into the claude-desktop MCP stanza "
|
|
357
|
+
"(default: %(default)s = full vault, matches `mcp-config`)")
|
|
358
|
+
sp.add_argument("--marketplace-source", default=_connect.DEFAULT_MARKETPLACE_SOURCE,
|
|
359
|
+
help="source passed to `claude plugin marketplace add` for "
|
|
360
|
+
"--client claude-code (default: %(default)s)")
|
|
361
|
+
sp.add_argument("--remove", action="store_true", help="unwire this client instead of wiring it")
|
|
362
|
+
sp.add_argument("--yes", action="store_true",
|
|
363
|
+
help="skip the interactive y/N confirmation (required when not a TTY)")
|
|
364
|
+
sp.add_argument("--json", action="store_true")
|
|
365
|
+
|
|
366
|
+
sp = sub.add_parser(
|
|
367
|
+
"update",
|
|
368
|
+
help="the ONE 'get current' command (ADR-0005 Ruling 3, UP-01/UP-02): "
|
|
369
|
+
"marketplace refresh -> downgrade-safe CLI-plugin reinstall -> "
|
|
370
|
+
"engine venv refresh -> workspace re-stage -> `brain doctor` "
|
|
371
|
+
"verify, one before->after table, one pass/fail (host only)",
|
|
372
|
+
)
|
|
373
|
+
sp.add_argument("--marketplace", default="brainiac",
|
|
374
|
+
help="marketplace name to refresh/compare against (default: %(default)s)")
|
|
375
|
+
sp.add_argument("--engine-src", default=None,
|
|
376
|
+
help="engine checkout to install -e from (default: resolved from "
|
|
377
|
+
"$BRAINIAC_ENGINE_SRC, else this repo's own root)")
|
|
378
|
+
sp.add_argument("--dry-run", action="store_true",
|
|
379
|
+
help="run every read/decision step for real but skip every mutating "
|
|
380
|
+
"call (marketplace update, plugin install/uninstall, pip install, "
|
|
381
|
+
"workspace re-stage) — prints what WOULD happen")
|
|
382
|
+
sp.add_argument("--skip-capability-probe", action="store_true",
|
|
383
|
+
help="skip the claude-plugin-CLI preflight probe (debugging only)")
|
|
384
|
+
sp.add_argument("--json", action="store_true")
|
|
385
|
+
|
|
386
|
+
# `search` and `hybrid-search` are the SAME fused RRF retrieval (RET-01);
|
|
387
|
+
# the second name is the explicit agentic-tool spelling (RET-04).
|
|
388
|
+
add_search("search", "fused RRF(60) BM25 + dense retrieval — hits carry "
|
|
389
|
+
"type/date/is_latest_version; response carries a "
|
|
390
|
+
"freshness block (react to it: see --help discipline)")
|
|
391
|
+
add_search("hybrid-search", "alias of `search`: fused RRF(60) BM25 + dense (RET-01)")
|
|
392
|
+
|
|
393
|
+
sp = sub.add_parser(
|
|
394
|
+
"dossier",
|
|
395
|
+
help="RET-10: the ONE-CALL retrieval sweep for decision-state questions — "
|
|
396
|
+
"decision-layer hits + corroborating sources + TENSIONS (newer "
|
|
397
|
+
"sources post-dating a recorded decision) + freshness, with "
|
|
398
|
+
"retired versions already excluded. Prefer this over plain "
|
|
399
|
+
"search when the question is 'what have we decided / what's "
|
|
400
|
+
"the current state'",
|
|
401
|
+
)
|
|
402
|
+
sp.add_argument("query")
|
|
403
|
+
sp.add_argument("-k", type=int, default=12, help="max live hits (default: 12)")
|
|
404
|
+
add_common(sp)
|
|
405
|
+
|
|
406
|
+
sp = sub.add_parser("grep", help="lexical-first exact/regex scan over notes — NO embedding (RET-04)")
|
|
407
|
+
sp.add_argument("pattern")
|
|
408
|
+
sp.add_argument("-k", type=int, default=20, help="max results (default: 20)")
|
|
409
|
+
sp.add_argument("--regex", action="store_true", help="treat pattern as a regex")
|
|
410
|
+
add_common(sp)
|
|
411
|
+
|
|
412
|
+
sp = sub.add_parser("bases-query", help="structured frontmatter view over indexed columns — NO embedding (RET-04)")
|
|
413
|
+
sp.add_argument("--where", action="append", default=[], metavar="KEY=VAL",
|
|
414
|
+
help="exact-match filter on id/title/type/classification/zone/path (repeatable)")
|
|
415
|
+
sp.add_argument("--latest-only", action="store_true",
|
|
416
|
+
help="TMP-02: exclude notes retired via `brain supersede` "
|
|
417
|
+
"(is_latest_version: false) — the Latest Only view")
|
|
418
|
+
sp.add_argument("--as-of", default=None, metavar="YYYY-MM-DD",
|
|
419
|
+
help="TMP-02: point-in-time view — notes valid on this date "
|
|
420
|
+
"(effective_date, else document_date, else created; "
|
|
421
|
+
"excludes anything superseded by then) — the As Of view")
|
|
422
|
+
sp.add_argument("-k", type=int, default=50, help="max results (default: 50)")
|
|
423
|
+
add_common(sp)
|
|
424
|
+
|
|
425
|
+
sp = sub.add_parser(
|
|
426
|
+
"supersede",
|
|
427
|
+
help="host-broker: retire <old-id> in favour of <new-id> — both sides "
|
|
428
|
+
"of the version chain, signed (TMP-02, ADR-0003 Ruling 2/8)",
|
|
429
|
+
)
|
|
430
|
+
sp.add_argument("old_id", metavar="old-id")
|
|
431
|
+
sp.add_argument("new_id", metavar="new-id")
|
|
432
|
+
sp.add_argument("--reason", default="")
|
|
433
|
+
sp.add_argument("--json", action="store_true")
|
|
434
|
+
|
|
435
|
+
sp = sub.add_parser("graph-expand", help="wikilink-BFS + PPR multi-hop expansion — DISCOVERY-ONLY (RET-03)")
|
|
436
|
+
sp.add_argument("seeds", nargs="+", help="seed note id(s)")
|
|
437
|
+
sp.add_argument("--depth", type=int, default=2, help="BFS hop depth (default: 2)")
|
|
438
|
+
sp.add_argument("-k", type=int, default=10, help="max candidates (default: 10)")
|
|
439
|
+
sp.add_argument("--no-ppr", action="store_true", help="BFS only, skip Personalized PageRank")
|
|
440
|
+
sp.add_argument(
|
|
441
|
+
"--use-inferred", action="store_true",
|
|
442
|
+
help="fold graphify's published INFERRED edges into the traversal too "
|
|
443
|
+
"(GRF-01, optional; host-only, silently ignored on role=vm)",
|
|
444
|
+
)
|
|
445
|
+
add_common(sp)
|
|
446
|
+
|
|
447
|
+
sp = sub.add_parser("get", help="fetch one note by id")
|
|
448
|
+
sp.add_argument("id")
|
|
449
|
+
add_common(sp)
|
|
450
|
+
|
|
451
|
+
sp = sub.add_parser("read", help="alias of `get`: read one full note by id (RET-04)")
|
|
452
|
+
sp.add_argument("id")
|
|
453
|
+
add_common(sp)
|
|
454
|
+
|
|
455
|
+
sp = sub.add_parser("recent", help="list recently updated notes")
|
|
456
|
+
sp.add_argument("-n", type=int, default=10, help="how many (default: 10)")
|
|
457
|
+
add_common(sp)
|
|
458
|
+
|
|
459
|
+
sp = sub.add_parser(
|
|
460
|
+
"draft-capture",
|
|
461
|
+
help="VM-side capture: stage a candidate note as a plain DRAFT "
|
|
462
|
+
"(no sign, no index, no WAL) for the host to drain later",
|
|
463
|
+
)
|
|
464
|
+
sp.add_argument("--id", default=None, help="note id (default: from frontmatter or content hash)")
|
|
465
|
+
sp.add_argument("--source", action="store_true", help="stage as a raw/ source (vs a brain/ note)")
|
|
466
|
+
sp.add_argument("--content", default=None, help="note text (default: read stdin)")
|
|
467
|
+
sp.add_argument("--json", action="store_true")
|
|
468
|
+
|
|
469
|
+
sp = sub.add_parser("rebuild", help="rebuild the derived index from vault/ (always safe)")
|
|
470
|
+
sp.add_argument("--json", action="store_true")
|
|
471
|
+
|
|
472
|
+
sp = sub.add_parser(
|
|
473
|
+
"warmup",
|
|
474
|
+
help="HOST-ONLY (S02/CS-01): resolve + download the live embedding "
|
|
475
|
+
"model now (stderr progress), instead of deferring to the first "
|
|
476
|
+
"real semantic search. Never on role=vm — the VM model is "
|
|
477
|
+
"pre-staged by the host and HuggingFace is off its egress "
|
|
478
|
+
"allowlist. Does not rebuild the index; run `brain sync` after "
|
|
479
|
+
"if `brain status` reported embedder: pending.",
|
|
480
|
+
)
|
|
481
|
+
sp.add_argument("--json", action="store_true")
|
|
482
|
+
|
|
483
|
+
sp = sub.add_parser(
|
|
484
|
+
"sync",
|
|
485
|
+
help="incremental upsert by path+hash + delete-propagation (no full rebuild); "
|
|
486
|
+
"drains capture drafts first (host)",
|
|
487
|
+
)
|
|
488
|
+
sp.add_argument("--no-drain", action="store_true",
|
|
489
|
+
help="skip the host capture drain (read-only/VM leg)")
|
|
490
|
+
sp.add_argument("--publish", action="store_true",
|
|
491
|
+
help="republish the read-only snapshot after reconcile so the VM's "
|
|
492
|
+
"next read sees the just-committed note (closes the capture loop)")
|
|
493
|
+
sp.add_argument("--json", action="store_true")
|
|
494
|
+
|
|
495
|
+
sp = sub.add_parser(
|
|
496
|
+
"snapshot", help="publish a read-only, generation-stamped index snapshot (host)")
|
|
497
|
+
sp.add_argument("--dest", default=None, help="snapshot dir (default: vault/.brain/snapshot)")
|
|
498
|
+
sp.add_argument("--json", action="store_true")
|
|
499
|
+
|
|
500
|
+
sp = sub.add_parser(
|
|
501
|
+
"restore-index",
|
|
502
|
+
help="fast-recover the live index from the published snapshot (host) — "
|
|
503
|
+
"seconds, no re-embed; use instead of `rebuild` when the index is corrupt/empty")
|
|
504
|
+
sp.add_argument("--force", action="store_true",
|
|
505
|
+
help="restore even if the live index has MORE notes than the snapshot "
|
|
506
|
+
"(the snapshot is older — you may lose notes)")
|
|
507
|
+
sp.add_argument("--dry-run", action="store_true", help="report what would happen; write nothing")
|
|
508
|
+
sp.add_argument("--json", action="store_true")
|
|
509
|
+
|
|
510
|
+
sp = sub.add_parser(
|
|
511
|
+
"status", help="report index stats + read-only snapshot generation/age")
|
|
512
|
+
sp.add_argument("--snapshot-dest", default=None)
|
|
513
|
+
sp.add_argument("--json", action="store_true")
|
|
514
|
+
|
|
515
|
+
sp = sub.add_parser("project", help="write a classification-filtered copy of the vault (real containment)")
|
|
516
|
+
sp.add_argument("--dest", required=True, help="destination directory (recreated each run)")
|
|
517
|
+
# project builds a workspace for an UNTRUSTED leg — it keeps the
|
|
518
|
+
# conservative Internal default even now that the host read surface
|
|
519
|
+
# defaults to the full vault (a full-vault containment copy is an
|
|
520
|
+
# explicit choice, never a default).
|
|
521
|
+
sp.add_argument("--max-tier", default="Internal", choices=cls.TIERS)
|
|
522
|
+
sp.add_argument("--json", action="store_true")
|
|
523
|
+
|
|
524
|
+
sp = sub.add_parser(
|
|
525
|
+
"ingest",
|
|
526
|
+
help="host-broker: drain <vault>/inbox/ — extract to Markdown, archive "
|
|
527
|
+
"originals immutably, commit through the signed write path (ING-01)",
|
|
528
|
+
)
|
|
529
|
+
sp.add_argument("--dry-run", action="store_true",
|
|
530
|
+
help="report what would happen; no moves, no writes, no signing")
|
|
531
|
+
sp.add_argument("--json", action="store_true")
|
|
532
|
+
|
|
533
|
+
sp = sub.add_parser(
|
|
534
|
+
"ingest-transcript",
|
|
535
|
+
help="host-broker: promote one transcript .md into raw/ with explicit "
|
|
536
|
+
"provenance (ING-04) — origin is a source audio/video path, or 'verbal'",
|
|
537
|
+
)
|
|
538
|
+
sp.add_argument("path", help="path to the transcript .md file")
|
|
539
|
+
sp.add_argument("--origin", required=True,
|
|
540
|
+
help="source audio/video file path, or the literal string 'verbal'")
|
|
541
|
+
sp.add_argument("--language", default=None, help="ISO 639-1 code (default: detected from filename)")
|
|
542
|
+
sp.add_argument("--document-date", default=None, dest="document_date",
|
|
543
|
+
help="YYYY-MM-DD the underlying meeting/recording happened (optional)")
|
|
544
|
+
sp.add_argument("--classification", default="Internal", choices=cls.TIERS)
|
|
545
|
+
sp.add_argument("--json", action="store_true")
|
|
546
|
+
|
|
547
|
+
sp = sub.add_parser("write", help="host-broker: write a note (audited, fails closed)")
|
|
548
|
+
sp.add_argument("relpath")
|
|
549
|
+
sp.add_argument("--content", default=None, help="content (default: read stdin)")
|
|
550
|
+
sp.add_argument("--reason", default="")
|
|
551
|
+
sp.add_argument("--json", action="store_true")
|
|
552
|
+
|
|
553
|
+
sp = sub.add_parser(
|
|
554
|
+
"audit-key",
|
|
555
|
+
help="host-broker: provision the audit signing key (create-if-absent, NEVER rotates)")
|
|
556
|
+
sp.add_argument("--json", action="store_true")
|
|
557
|
+
|
|
558
|
+
sp = sub.add_parser("verify-audit", help="verify the Ed25519 audit chain")
|
|
559
|
+
sp.add_argument("--json", action="store_true")
|
|
560
|
+
|
|
561
|
+
sp = sub.add_parser("anchor", help="publish the signed chain head OFF-HOST (host; SEC-03)")
|
|
562
|
+
sp.add_argument("--anchor-dir", required=True, help="off-host append-only anchor dir")
|
|
563
|
+
sp.add_argument("--json", action="store_true")
|
|
564
|
+
|
|
565
|
+
sp = sub.add_parser("verify-anchor", help="verify the live chain vs the off-host anchor (detect rewrite)")
|
|
566
|
+
sp.add_argument("--anchor-dir", required=True)
|
|
567
|
+
sp.add_argument("--json", action="store_true")
|
|
568
|
+
|
|
569
|
+
sp = sub.add_parser("backup", help="encrypted off-device backup of the Markdown truth (host; SEC-03)")
|
|
570
|
+
sp.add_argument("--dest", required=True, help="off-device destination dir")
|
|
571
|
+
sp.add_argument("--no-encrypt", action="store_true",
|
|
572
|
+
help="write a PLAINTEXT archive (discouraged off-device; default encrypts)")
|
|
573
|
+
sp.add_argument("--json", action="store_true")
|
|
574
|
+
|
|
575
|
+
sp = sub.add_parser("restore", help="restore (decrypt) a backup archive (host)")
|
|
576
|
+
sp.add_argument("--archive", required=True)
|
|
577
|
+
sp.add_argument("--dest", required=True, help="restore destination dir")
|
|
578
|
+
sp.add_argument("--json", action="store_true")
|
|
579
|
+
|
|
580
|
+
# -- UX layer (UX-01 / UX-02 / UX-03) ---------------------------------
|
|
581
|
+
sp = sub.add_parser(
|
|
582
|
+
"capture",
|
|
583
|
+
help="capture a note: HOST signs+writes+syncs; VM drops unsigned draft to capture-inbox/ (UX-01)",
|
|
584
|
+
)
|
|
585
|
+
sp.add_argument("--id", default=None, help="note id (default: derived from content hash)")
|
|
586
|
+
sp.add_argument("--type", default=None, dest="note_type",
|
|
587
|
+
help="note type (default: note)")
|
|
588
|
+
sp.add_argument("--classification", default=None, choices=cls.TIERS,
|
|
589
|
+
help="classification tier (default: Internal)")
|
|
590
|
+
sp.add_argument("--content", default=None, help="note text (default: read stdin)")
|
|
591
|
+
sp.add_argument("--reason", default="", help="audit reason (host only)")
|
|
592
|
+
sp.add_argument("--json", action="store_true")
|
|
593
|
+
|
|
594
|
+
sp = sub.add_parser(
|
|
595
|
+
"brief",
|
|
596
|
+
help="morning brief: drains pending captures (host) + quiet index summary (UX-02)",
|
|
597
|
+
)
|
|
598
|
+
sp.add_argument("-n", type=int, default=5, help="max recent notes to show (default: 5)")
|
|
599
|
+
sp.add_argument("--no-drain", action="store_true",
|
|
600
|
+
help="skip the capture drain (VM / read-only mode)")
|
|
601
|
+
sp.add_argument("--max-tier", default=None, choices=cls.TIERS)
|
|
602
|
+
sp.add_argument("--json", action="store_true")
|
|
603
|
+
sp.add_argument(
|
|
604
|
+
"--html", action="store_true",
|
|
605
|
+
help="write a self-contained, overlay-branded HTML brief to .brain/brief/ "
|
|
606
|
+
"(host-only — a new file-egress surface, ADR-0003 Ruling c; refused on role=vm)",
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
sp = sub.add_parser(
|
|
610
|
+
"digest",
|
|
611
|
+
help="weekly digest: notes added/updated in the past N days (UX-02)",
|
|
612
|
+
)
|
|
613
|
+
sp.add_argument("--days", type=int, default=7, help="lookback period in days (default: 7)")
|
|
614
|
+
sp.add_argument("--max-tier", default=None, choices=cls.TIERS)
|
|
615
|
+
sp.add_argument("--json", action="store_true")
|
|
616
|
+
sp.add_argument(
|
|
617
|
+
"--html", action="store_true",
|
|
618
|
+
help="write a self-contained, overlay-branded HTML digest to .brain/brief/ "
|
|
619
|
+
"(host-only — a new file-egress surface, ADR-0003 Ruling c; refused on role=vm)",
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
# -- maintenance rituals (CUT-03) — HOST-broker only, refused on role=vm --
|
|
623
|
+
sp = sub.add_parser(
|
|
624
|
+
"check", help="daily-check fold: index reconcile + drain drafts + status (host)")
|
|
625
|
+
sp.add_argument("--dry-run", action="store_true", help="report only; no sync/drain")
|
|
626
|
+
sp.add_argument("--json", action="store_true")
|
|
627
|
+
|
|
628
|
+
sp = sub.add_parser(
|
|
629
|
+
"health", help="health fold: status + audit-chain verify + substrate self-test (host)")
|
|
630
|
+
sp.add_argument("--json", action="store_true")
|
|
631
|
+
|
|
632
|
+
sp = sub.add_parser(
|
|
633
|
+
"curate",
|
|
634
|
+
help="curation fold: refresh-index + unclassified-notes lint + "
|
|
635
|
+
"stale-wikilink-target detection + age x centrality revisit sample (host); "
|
|
636
|
+
"orphan/contradiction/callout lint stay vault-overlay (no brain equivalent)",
|
|
637
|
+
)
|
|
638
|
+
sp.add_argument("--dry-run", action="store_true", help="report only; no refresh-index")
|
|
639
|
+
sp.add_argument("-k", type=int, default=50, help="max findings (default: 50)")
|
|
640
|
+
add_common(sp)
|
|
641
|
+
|
|
642
|
+
sp = sub.add_parser(
|
|
643
|
+
"integrity",
|
|
644
|
+
help="integrity-scan fold: audit-chain verify + corpus-wide near-dup scan "
|
|
645
|
+
"directly over the brain vector backend (host; G1)",
|
|
646
|
+
)
|
|
647
|
+
sp.add_argument("--min-score", type=float, default=0.95,
|
|
648
|
+
help="near-dup cosine threshold (default: 0.95)")
|
|
649
|
+
sp.add_argument("-k", type=int, default=5, help="ANN probe depth per note (default: 5)")
|
|
650
|
+
add_common(sp)
|
|
651
|
+
|
|
652
|
+
sp = sub.add_parser(
|
|
653
|
+
"promote-scan",
|
|
654
|
+
help="promotion-scan fold: triage raw/ sources not yet promoted to a "
|
|
655
|
+
"typed brain/ note (host; promotion itself stays a human gate)",
|
|
656
|
+
)
|
|
657
|
+
sp.add_argument("-k", type=int, default=50, help="max candidates (default: 50)")
|
|
658
|
+
add_common(sp)
|
|
659
|
+
|
|
660
|
+
sp = sub.add_parser(
|
|
661
|
+
"sweep-workspace",
|
|
662
|
+
help="WSP-01: move SETTLED top-level files (mtime older than --age-days) "
|
|
663
|
+
"from configured working folder(s) into <vault>/inbox/ for the "
|
|
664
|
+
"standard ingest drain — the lifecycle for session-artifact dumping "
|
|
665
|
+
"grounds. Sources: --dir (repeatable) or $BRAIN_WORKSPACE_SWEEP_DIRS. "
|
|
666
|
+
"Subdirectories and dotfiles are never touched; already-ingested "
|
|
667
|
+
"content dedups by hash downstream. Runs inside the nightly "
|
|
668
|
+
"maintain automatically when configured (host-only)",
|
|
669
|
+
)
|
|
670
|
+
sp.add_argument("--dir", action="append", default=None, dest="dirs",
|
|
671
|
+
help="workspace folder to sweep (repeatable; default: "
|
|
672
|
+
"$BRAIN_WORKSPACE_SWEEP_DIRS)")
|
|
673
|
+
sp.add_argument("--age-days", type=int, default=None,
|
|
674
|
+
help="settled threshold in days (default: "
|
|
675
|
+
"$BRAIN_WORKSPACE_SWEEP_AGE_DAYS or 14)")
|
|
676
|
+
sp.add_argument("--dry-run", action="store_true",
|
|
677
|
+
help="report what would move; touch nothing")
|
|
678
|
+
sp.add_argument("--json", action="store_true")
|
|
679
|
+
|
|
680
|
+
sp = sub.add_parser(
|
|
681
|
+
"maintain",
|
|
682
|
+
help="the umbrella: THE single sanctioned host task (brain-nightly) — "
|
|
683
|
+
"workspace sweep (when configured) + sync --publish + brief + "
|
|
684
|
+
"recommendations-aging fold, plus date-gated "
|
|
685
|
+
"health/integrity/digest(+curate+promote-scan)/graphify branches; "
|
|
686
|
+
"due-since-last-run catch-up + single-runner lock (ADR-0003 Ruling 5/d)",
|
|
687
|
+
)
|
|
688
|
+
sp.add_argument("--dry-run", action="store_true",
|
|
689
|
+
help="skip sync/drain/publish/signing; still runs the real "
|
|
690
|
+
"read-only health/integrity probes for any due branch")
|
|
691
|
+
sp.add_argument("--date", default=None,
|
|
692
|
+
help="YYYY-MM-DD override for date-gate testing (default: today)")
|
|
693
|
+
sp.add_argument("--min-score", type=float, default=0.95,
|
|
694
|
+
help="near-dup cosine threshold on a due Tuesday branch (default: 0.95)")
|
|
695
|
+
sp.add_argument("--json", action="store_true")
|
|
696
|
+
|
|
697
|
+
sp = sub.add_parser(
|
|
698
|
+
"graphify",
|
|
699
|
+
help="graphify discovery build: derived, non-authoritative graph "
|
|
700
|
+
"(wikilinks + capped embedding-neighbour INFERRED edges) + "
|
|
701
|
+
"human-review link candidates (host; ADR-0003 Ruling 6/(a))",
|
|
702
|
+
)
|
|
703
|
+
sp.add_argument("--force", action="store_true",
|
|
704
|
+
help="bypass the corpus-drift gate and rebuild anyway")
|
|
705
|
+
sp.add_argument("--dry-run", action="store_true",
|
|
706
|
+
help="build + report only; never publish graph.json")
|
|
707
|
+
sp.add_argument("-n", type=int, default=20, help="max candidates to surface (default: 20)")
|
|
708
|
+
add_common(sp)
|
|
709
|
+
|
|
710
|
+
return p
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _make_core(args: Any, role: str) -> BrainCore:
|
|
714
|
+
"""Construct BrainCore with the resolved role. Tolerant of a test double that
|
|
715
|
+
patched ``cli.BrainCore`` with a vault-only signature (back-compat)."""
|
|
716
|
+
try:
|
|
717
|
+
return BrainCore(vault=args.vault, role=role)
|
|
718
|
+
except TypeError:
|
|
719
|
+
return BrainCore(vault=args.vault)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _connect_confirm(preview: str, args: Any) -> bool:
|
|
723
|
+
"""The one confirmation gate every `connect` mutation goes through:
|
|
724
|
+
--yes always proceeds; --json never prompts (automation must pass --yes
|
|
725
|
+
explicitly, same contract as `init --import-from`); otherwise prompt on a
|
|
726
|
+
real TTY, and refuse (caller prints the preview + exits non-zero) when
|
|
727
|
+
neither holds."""
|
|
728
|
+
if args.yes:
|
|
729
|
+
return True
|
|
730
|
+
if args.json or not sys.stdin.isatty():
|
|
731
|
+
return False
|
|
732
|
+
sys.stdout.write(preview + "\n")
|
|
733
|
+
ans = input("Proceed? [y/N] ").strip().lower()
|
|
734
|
+
return ans in ("y", "yes")
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _connect_file_step(plan: "_connect.ConnectPlan", args: Any, *, remove: bool) -> dict:
|
|
738
|
+
"""Diff -> confirm -> write, for one file-based connect plan (both the
|
|
739
|
+
Markdown marked-block clients and the JSON-merge clients share this)."""
|
|
740
|
+
if plan.action == "noop" and not remove:
|
|
741
|
+
return {"path": str(plan.target_path), "action": "noop",
|
|
742
|
+
"already_connected": True, "diff": ""}
|
|
743
|
+
if plan.action == "noop" and remove:
|
|
744
|
+
return {"path": str(plan.target_path), "action": "noop",
|
|
745
|
+
"detail": "nothing to unwire", "diff": ""}
|
|
746
|
+
if not _connect_confirm(plan.diff or f"(would create {plan.target_path})", args):
|
|
747
|
+
return {"path": str(plan.target_path), "action": plan.action,
|
|
748
|
+
"diff": plan.diff, "confirmed": False,
|
|
749
|
+
"detail": "not confirmed — pass --yes to proceed non-interactively"}
|
|
750
|
+
if remove:
|
|
751
|
+
_connect.apply_remove_marked_block(plan)
|
|
752
|
+
else:
|
|
753
|
+
if plan.target_path.suffix == ".json":
|
|
754
|
+
_connect.apply_json_merge(plan)
|
|
755
|
+
else:
|
|
756
|
+
_connect.apply_marked_block(plan)
|
|
757
|
+
return {"path": str(plan.target_path), "action": plan.action,
|
|
758
|
+
"diff": plan.diff, "confirmed": True}
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _cmd_connect(args: Any) -> int:
|
|
762
|
+
from pathlib import Path
|
|
763
|
+
|
|
764
|
+
from . import config
|
|
765
|
+
|
|
766
|
+
client = args.client
|
|
767
|
+
remove = args.remove
|
|
768
|
+
target_dir = Path(args.target).resolve()
|
|
769
|
+
vault = str(Path(config.vault_root(args.vault)).resolve())
|
|
770
|
+
steps: list[dict] = []
|
|
771
|
+
ok = True
|
|
772
|
+
|
|
773
|
+
if client == "claude-desktop":
|
|
774
|
+
path = _connect.claude_desktop_config_path()
|
|
775
|
+
if remove:
|
|
776
|
+
found = _connect.plan_restore_from_backup(path)
|
|
777
|
+
if not found["ok"]:
|
|
778
|
+
steps.append({"path": str(path), "action": "noop", "detail": found["reason"]})
|
|
779
|
+
elif _connect_confirm(f"restore {path} from backup {found['backup']}", args):
|
|
780
|
+
_connect.apply_restore_from_backup(path, Path(found["backup"]))
|
|
781
|
+
steps.append({"path": str(path), "action": "restore",
|
|
782
|
+
"backup": found["backup"], "confirmed": True})
|
|
783
|
+
else:
|
|
784
|
+
steps.append({"path": str(path), "action": "restore", "confirmed": False,
|
|
785
|
+
"detail": "not confirmed — pass --yes to proceed non-interactively"})
|
|
786
|
+
ok = False
|
|
787
|
+
else:
|
|
788
|
+
plan = _connect.plan_claude_desktop(path, vault, args.name, args.max_tier)
|
|
789
|
+
step = _connect_file_step(plan, args, remove=False)
|
|
790
|
+
steps.append(step)
|
|
791
|
+
ok = step.get("already_connected") or step.get("confirmed", False)
|
|
792
|
+
|
|
793
|
+
elif client == "gemini":
|
|
794
|
+
path = target_dir / ".gemini" / "settings.json"
|
|
795
|
+
if remove:
|
|
796
|
+
found = _connect.plan_restore_from_backup(path)
|
|
797
|
+
if not found["ok"]:
|
|
798
|
+
steps.append({"path": str(path), "action": "noop", "detail": found["reason"]})
|
|
799
|
+
elif _connect_confirm(f"restore {path} from backup {found['backup']}", args):
|
|
800
|
+
_connect.apply_restore_from_backup(path, Path(found["backup"]))
|
|
801
|
+
steps.append({"path": str(path), "action": "restore",
|
|
802
|
+
"backup": found["backup"], "confirmed": True})
|
|
803
|
+
else:
|
|
804
|
+
steps.append({"path": str(path), "action": "restore", "confirmed": False,
|
|
805
|
+
"detail": "not confirmed — pass --yes to proceed non-interactively"})
|
|
806
|
+
ok = False
|
|
807
|
+
else:
|
|
808
|
+
plan = _connect.plan_gemini(path)
|
|
809
|
+
step = _connect_file_step(plan, args, remove=False)
|
|
810
|
+
steps.append(step)
|
|
811
|
+
ok = step.get("already_connected") or step.get("confirmed", False)
|
|
812
|
+
|
|
813
|
+
elif client == "codex":
|
|
814
|
+
path = target_dir / "AGENTS.md"
|
|
815
|
+
if remove:
|
|
816
|
+
plan = _connect.plan_remove_marked_block(path)
|
|
817
|
+
step = _connect_file_step(plan, args, remove=True)
|
|
818
|
+
else:
|
|
819
|
+
plan = _connect.plan_marked_block(path)
|
|
820
|
+
step = _connect_file_step(plan, args, remove=False)
|
|
821
|
+
steps.append(step)
|
|
822
|
+
ok = step.get("already_connected") or step.get("confirmed", False) or step["action"] == "noop"
|
|
823
|
+
|
|
824
|
+
elif client == "claude-code":
|
|
825
|
+
if remove:
|
|
826
|
+
available = _connect.claude_plugin_cli_available()
|
|
827
|
+
if available and _connect_confirm(
|
|
828
|
+
f"claude plugin uninstall {_connect.KERNEL_PLUGIN}@{_connect.MARKETPLACE_NAME}", args):
|
|
829
|
+
result = _connect.run_claude_code_plugin_uninstall(
|
|
830
|
+
claude_home=Path.home() / ".claude")
|
|
831
|
+
steps.append({"kind": "plugin", "confirmed": True, **result})
|
|
832
|
+
ok = result["ok"]
|
|
833
|
+
elif not available:
|
|
834
|
+
steps.append({"kind": "plugin", "detail": "`claude plugin` CLI not available; "
|
|
835
|
+
f"run manually: claude plugin uninstall "
|
|
836
|
+
f"{_connect.KERNEL_PLUGIN}@{_connect.MARKETPLACE_NAME}"})
|
|
837
|
+
else:
|
|
838
|
+
steps.append({"kind": "plugin", "confirmed": False,
|
|
839
|
+
"detail": "not confirmed — pass --yes to proceed non-interactively"})
|
|
840
|
+
ok = False
|
|
841
|
+
path = target_dir / "CLAUDE.md"
|
|
842
|
+
plan = _connect.plan_remove_marked_block(path)
|
|
843
|
+
step = _connect_file_step(plan, args, remove=True)
|
|
844
|
+
steps.append(step)
|
|
845
|
+
ok = ok and (step.get("already_connected") or step.get("confirmed", False) or step["action"] == "noop")
|
|
846
|
+
else:
|
|
847
|
+
claude_home = Path.home() / ".claude"
|
|
848
|
+
already_installed = _connect.is_plugin_installed(claude_home)
|
|
849
|
+
if already_installed:
|
|
850
|
+
steps.append({"kind": "plugin", "action": "noop", "already_connected": True})
|
|
851
|
+
else:
|
|
852
|
+
available = _connect.claude_plugin_cli_available()
|
|
853
|
+
cmds = _connect.claude_code_plugin_commands(args.marketplace_source)
|
|
854
|
+
preview = "\n".join(" ".join(c) for c in cmds)
|
|
855
|
+
if not available:
|
|
856
|
+
steps.append({
|
|
857
|
+
"kind": "plugin", "action": "manual",
|
|
858
|
+
"detail": "`claude` plugin CLI not detected/usable — run these two "
|
|
859
|
+
"commands yourself (guided, not one-command, for this client):",
|
|
860
|
+
"commands": [" ".join(c) for c in cmds],
|
|
861
|
+
})
|
|
862
|
+
elif _connect_confirm(preview, args):
|
|
863
|
+
result = _connect.run_claude_code_plugin_install(
|
|
864
|
+
marketplace_source=args.marketplace_source, claude_home=claude_home)
|
|
865
|
+
steps.append({"kind": "plugin", "confirmed": True, **result})
|
|
866
|
+
ok = result["ok"]
|
|
867
|
+
else:
|
|
868
|
+
steps.append({"kind": "plugin", "confirmed": False, "commands": [" ".join(c) for c in cmds],
|
|
869
|
+
"detail": "not confirmed — pass --yes to proceed non-interactively"})
|
|
870
|
+
ok = False
|
|
871
|
+
path = target_dir / "CLAUDE.md"
|
|
872
|
+
plan = _connect.plan_marked_block(path)
|
|
873
|
+
step = _connect_file_step(plan, args, remove=False)
|
|
874
|
+
steps.append(step)
|
|
875
|
+
ok = ok and (step.get("already_connected") or step.get("confirmed", False))
|
|
876
|
+
|
|
877
|
+
report = {"client": client, "removed": remove, "steps": steps, "ok": ok}
|
|
878
|
+
if args.json:
|
|
879
|
+
_emit(report, True)
|
|
880
|
+
else:
|
|
881
|
+
lines = [f"brain connect --client {client}{' --remove' if remove else ''} — "
|
|
882
|
+
f"{'OK' if ok else 'INCOMPLETE'}"]
|
|
883
|
+
for step in steps:
|
|
884
|
+
if step.get("already_connected"):
|
|
885
|
+
lines.append(f" {step.get('path', step.get('kind'))}: already connected")
|
|
886
|
+
elif step.get("confirmed"):
|
|
887
|
+
lines.append(f" {step.get('path', step.get('kind'))}: wired")
|
|
888
|
+
else:
|
|
889
|
+
lines.append(f" {step.get('path', step.get('kind'))}: {step.get('detail', step.get('action'))}")
|
|
890
|
+
if step.get("diff"):
|
|
891
|
+
lines.append(step["diff"])
|
|
892
|
+
if step.get("commands"):
|
|
893
|
+
lines.extend(f" {c}" for c in step["commands"])
|
|
894
|
+
_emit(None, False, "\n".join(lines))
|
|
895
|
+
return 0 if ok else 2
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
# Commands the read+draft-only VM leg may run. Everything else is host-broker.
|
|
899
|
+
# capture/brief/digest are included because BrainCore routes correctly by role:
|
|
900
|
+
# capture → draft_capture (VM), write_note (host)
|
|
901
|
+
# brief/digest → read-only stats (VM), drain+stats (host)
|
|
902
|
+
# DECISION (H-1, s02): brief/digest STAY in VM_ALLOWED. Gating their output and
|
|
903
|
+
# VM membership are separate questions — this call is now explicit rather than
|
|
904
|
+
# left implicit. Rationale: once routed through egress.apply_gate (this
|
|
905
|
+
# session), brief/digest are exactly as safe on the VM leg as `recent` /
|
|
906
|
+
# `search` — read-only, no signing key touched, deny-by-default classification
|
|
907
|
+
# filter applied before the summary is assembled. Revisit ONLY if a future
|
|
908
|
+
# brief/digest field starts drawing from an ungated source (e.g. raw audit/WAL
|
|
909
|
+
# internals) — that would need its own gate or host-only demotion, not a
|
|
910
|
+
# blanket VM_ALLOWED removal.
|
|
911
|
+
# The CUT-03 maintenance rituals (check/health/curate/integrity/promote-scan/
|
|
912
|
+
# maintain) are DELIBERATELY ABSENT here: task-disposition.md calls every one
|
|
913
|
+
# of them a write ritual (regen index, sign+drain, query the audit chain), so
|
|
914
|
+
# they are host-broker only — refused on role=vm at this gate (defense in
|
|
915
|
+
# depth on top of each BrainCore method's own _require_host()).
|
|
916
|
+
VM_ALLOWED = frozenset({
|
|
917
|
+
"init", # filesystem-only overlay validation; safe on either role
|
|
918
|
+
"doctor", # read-only version/health inspection; no index/key touched
|
|
919
|
+
"mcp-config", # prints a config string; no index/key/vault read
|
|
920
|
+
"search", "hybrid-search", "dossier", "grep", "bases-query", "graph-expand",
|
|
921
|
+
"get", "read", "recent", "status", "draft-capture",
|
|
922
|
+
"capture", "brief", "digest",
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def main(argv: list[str] | None = None) -> int:
|
|
927
|
+
try:
|
|
928
|
+
return _main(argv)
|
|
929
|
+
except Exception as exc: # H-4: top-level guard -- never a raw traceback
|
|
930
|
+
raw_args = argv if argv is not None else sys.argv[1:]
|
|
931
|
+
as_json = "--json" in raw_args
|
|
932
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if as_json
|
|
933
|
+
else f"{exc.__class__.__name__}: {exc}", as_json)
|
|
934
|
+
return 3
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _main(argv: list[str] | None = None) -> int:
|
|
938
|
+
from . import config
|
|
939
|
+
|
|
940
|
+
args = build_parser().parse_args(argv)
|
|
941
|
+
role = config.role(getattr(args, "role", None))
|
|
942
|
+
# Role-aware egress default (owner decision, 2026-07-10): the trusted host
|
|
943
|
+
# surfaces the FULL vault unless --max-tier narrows it; the untrusted VM
|
|
944
|
+
# leg keeps the conservative Internal cap. An explicit --max-tier always
|
|
945
|
+
# wins on either role — argparse leaves it None only when the flag was
|
|
946
|
+
# not typed.
|
|
947
|
+
if getattr(args, "max_tier", "unset") is None:
|
|
948
|
+
args.max_tier = (cls.VM_DEFAULT_MAX_TIER if role == config.ROLE_VM
|
|
949
|
+
else cls.DEFAULT_MAX_TIER)
|
|
950
|
+
# DV-03: the VM leg fails closed on a dead embedder rather than silently
|
|
951
|
+
# answering semantic queries with random hash vectors (no-op when a real
|
|
952
|
+
# embedder is present or hash was chosen explicitly).
|
|
953
|
+
config.apply_role_embedder_policy(role)
|
|
954
|
+
cmd = args.cmd
|
|
955
|
+
|
|
956
|
+
# VM trust gate: refuse host-broker commands on the VM leg BEFORE constructing
|
|
957
|
+
# BrainCore — no index open, no key resolution on a disallowed verb.
|
|
958
|
+
if role == config.ROLE_VM and cmd not in VM_ALLOWED:
|
|
959
|
+
msg = {
|
|
960
|
+
"error": "role_forbidden",
|
|
961
|
+
"role": role,
|
|
962
|
+
"cmd": cmd,
|
|
963
|
+
"detail": f"'{cmd}' is a host-broker command; the VM leg is read + draft only "
|
|
964
|
+
f"(allowed: {sorted(VM_ALLOWED)})",
|
|
965
|
+
}
|
|
966
|
+
_emit(msg if getattr(args, "json", False) else
|
|
967
|
+
f"refused: '{cmd}' is host-broker only (role=vm is read+draft). "
|
|
968
|
+
f"Run it on the host.", getattr(args, "json", False))
|
|
969
|
+
return 4
|
|
970
|
+
|
|
971
|
+
# `init` is filesystem-only (PER-02 minimal slice) — dispatch BEFORE
|
|
972
|
+
# BrainCore construction so a brand-new install (no index yet) still works.
|
|
973
|
+
if cmd == "init":
|
|
974
|
+
from . import overlay as ov
|
|
975
|
+
|
|
976
|
+
if args.full:
|
|
977
|
+
from . import init as brain_init
|
|
978
|
+
|
|
979
|
+
import_report: dict[str, Any] | None = None
|
|
980
|
+
if args.import_from:
|
|
981
|
+
# ONB-01: refused BEFORE any filesystem side effect -- no
|
|
982
|
+
# dry-run scan, no read of the import folder at all. `init`
|
|
983
|
+
# itself is VM_ALLOWED (filesystem-only overlay setup), but
|
|
984
|
+
# --import-from drives the host-only ingest drain, so it
|
|
985
|
+
# gets its own gate here, same shape as the general VM
|
|
986
|
+
# trust-gate refusal above.
|
|
987
|
+
if role == config.ROLE_VM:
|
|
988
|
+
msg = {
|
|
989
|
+
"error": "role_forbidden", "role": role, "cmd": "init --import-from",
|
|
990
|
+
"detail": "'init --import-from' stages + ingests a folder via "
|
|
991
|
+
"the host ingest drain; the VM leg is read + draft "
|
|
992
|
+
"only. Run it on the host.",
|
|
993
|
+
}
|
|
994
|
+
_emit(msg if args.json else
|
|
995
|
+
"refused: 'init --import-from' is host-broker only "
|
|
996
|
+
"(role=vm is read+draft). Run it on the host.", args.json)
|
|
997
|
+
return 4
|
|
998
|
+
try:
|
|
999
|
+
dry_run = brain_init.build_import_dry_run(
|
|
1000
|
+
args.import_from, args.vault, force=args.import_force)
|
|
1001
|
+
except brain_init.ImportSafetyError as exc:
|
|
1002
|
+
_emit({"error": "import_safety", "detail": str(exc)} if args.json
|
|
1003
|
+
else f"import refused: {exc}", args.json)
|
|
1004
|
+
return 2
|
|
1005
|
+
proceed = args.yes
|
|
1006
|
+
dry_run_printed = False
|
|
1007
|
+
if not proceed and not args.json and sys.stdin.isatty():
|
|
1008
|
+
sys.stdout.write(brain_init.render_import_dry_run(dry_run) + "\n")
|
|
1009
|
+
dry_run_printed = True
|
|
1010
|
+
ans = input("Proceed with staging + ingest? [y/N] ").strip().lower()
|
|
1011
|
+
proceed = ans in ("y", "yes")
|
|
1012
|
+
if not proceed:
|
|
1013
|
+
if args.json:
|
|
1014
|
+
_emit({"action": "init-import-dry-run", "manifest":
|
|
1015
|
+
{k: v for k, v in dry_run.items() if k != "_files"},
|
|
1016
|
+
"hint": "re-run with --yes to stage + ingest"}, True)
|
|
1017
|
+
else:
|
|
1018
|
+
human = "aborted: pass --yes to proceed non-interactively"
|
|
1019
|
+
if not dry_run_printed:
|
|
1020
|
+
human = brain_init.render_import_dry_run(dry_run) + "\n\n" + human
|
|
1021
|
+
_emit(None, False, human)
|
|
1022
|
+
return 2
|
|
1023
|
+
import_report = brain_init.stage_and_ingest_import(
|
|
1024
|
+
args.import_from, args.vault, role, force=args.import_force)
|
|
1025
|
+
|
|
1026
|
+
report = brain_init.run_full_init(
|
|
1027
|
+
vault=args.vault,
|
|
1028
|
+
overlay_dir=args.overlay_dir,
|
|
1029
|
+
role=role,
|
|
1030
|
+
scaffold=args.scaffold_overlay,
|
|
1031
|
+
template_dir=args.template_dir,
|
|
1032
|
+
register_tasks=args.register_tasks,
|
|
1033
|
+
apply=args.apply,
|
|
1034
|
+
manifest=args.manifest,
|
|
1035
|
+
save_cowork_prompt=args.save_cowork_prompt,
|
|
1036
|
+
seed_vault=args.seed_vault,
|
|
1037
|
+
)
|
|
1038
|
+
if import_report is not None:
|
|
1039
|
+
report["import"] = import_report
|
|
1040
|
+
_emit(report if args.json else None, args.json,
|
|
1041
|
+
None if args.json else brain_init.render_human(report))
|
|
1042
|
+
return 0 if report["ok"] else 1
|
|
1043
|
+
|
|
1044
|
+
if not args.validate_overlay:
|
|
1045
|
+
detail = ("brain init: choose a mode — --validate-overlay (PER-02 shape "
|
|
1046
|
+
"check) or --full (INS-02 full install orchestration: "
|
|
1047
|
+
"overlay + per-client task registration). "
|
|
1048
|
+
"Run: brain init --validate-overlay | brain init --full")
|
|
1049
|
+
_emit({"error": "no_mode", "detail": detail} if args.json else detail,
|
|
1050
|
+
args.json)
|
|
1051
|
+
return 2
|
|
1052
|
+
path = ov.overlay_dir(args.vault, args.overlay_dir)
|
|
1053
|
+
report = ov.validate_overlay(path)
|
|
1054
|
+
if args.json:
|
|
1055
|
+
_emit(report, True)
|
|
1056
|
+
else:
|
|
1057
|
+
lines = [f"overlay: {report['overlay_dir']}", f"valid: {report['valid']}"]
|
|
1058
|
+
for cat, info in report["categories"].items():
|
|
1059
|
+
status = "ok" if not info["issues"] else "ISSUES"
|
|
1060
|
+
lines.append(f" {cat}/: {status} ({info['file_count']} file(s))")
|
|
1061
|
+
for issue in info["issues"]:
|
|
1062
|
+
lines.append(f" - {issue}")
|
|
1063
|
+
_emit(None, False, "\n".join(lines))
|
|
1064
|
+
return 0 if report["valid"] else 1
|
|
1065
|
+
|
|
1066
|
+
# `doctor` is pure filesystem/subprocess inspection (ADR-0005 Ruling 2) —
|
|
1067
|
+
# dispatch BEFORE BrainCore construction, same reasoning as `init`: it
|
|
1068
|
+
# must work even against a vault with no index built yet, and it never
|
|
1069
|
+
# touches the vault at all.
|
|
1070
|
+
if cmd == "doctor":
|
|
1071
|
+
from . import doctor as brain_doctor
|
|
1072
|
+
|
|
1073
|
+
# Role-aware (2026-07-07 addendum, ADR-0005 Ruling 2): the VM leg only
|
|
1074
|
+
# ever sees the staged zero-install copy, so it gets its own surface
|
|
1075
|
+
# set. Structural fallback covers the staged shim, which invokes
|
|
1076
|
+
# `python3 -m brain.cli "$@"` directly and never sets $BRAIN_ROLE.
|
|
1077
|
+
vm_posture = role == config.ROLE_VM or brain_doctor.looks_like_vm_stage()
|
|
1078
|
+
if vm_posture:
|
|
1079
|
+
report = brain_doctor.run_doctor_vm(vault=args.vault)
|
|
1080
|
+
else:
|
|
1081
|
+
registry_fetch = None
|
|
1082
|
+
if getattr(args, "check_registry", False):
|
|
1083
|
+
def registry_fetch(): # noqa: E306 - single cached HTTPS read, opt-in only
|
|
1084
|
+
return {"pypi_version": brain_doctor.fetch_pypi_latest_version()}
|
|
1085
|
+
report = brain_doctor.run_doctor(registry_fetch=registry_fetch)
|
|
1086
|
+
_emit(report if args.json else None, args.json,
|
|
1087
|
+
None if args.json else brain_doctor.render_human(report))
|
|
1088
|
+
return 0 if report["ok"] else 1
|
|
1089
|
+
|
|
1090
|
+
# `mcp-config` prints the MCP-client entry to run brain-mcp against this
|
|
1091
|
+
# vault (host-side stdio server, same pattern as Smart Connections' MCP).
|
|
1092
|
+
# Pure string generation — no BrainCore, no index, no key.
|
|
1093
|
+
if cmd == "mcp-config":
|
|
1094
|
+
from pathlib import Path
|
|
1095
|
+
|
|
1096
|
+
vault = str(Path(config.vault_root(args.vault)).resolve())
|
|
1097
|
+
# Same entry shape `brain connect --client claude-desktop` WRITES
|
|
1098
|
+
# (connect.mcp_server_entry) — one builder, so print-only and
|
|
1099
|
+
# write-for-real can never drift apart (SUI-02 reconciliation).
|
|
1100
|
+
entry = _connect.mcp_server_entry(vault, args.name, args.max_tier)
|
|
1101
|
+
if args.json:
|
|
1102
|
+
_emit(entry, True)
|
|
1103
|
+
else:
|
|
1104
|
+
body = json.dumps(entry, indent=2)
|
|
1105
|
+
_emit(None, False,
|
|
1106
|
+
"Add this inside \"mcpServers\" in your MCP client config, then "
|
|
1107
|
+
"restart the client:\n"
|
|
1108
|
+
" Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json\n"
|
|
1109
|
+
" Claude Code: ~/.claude.json (or `claude mcp add`)\n\n" + body)
|
|
1110
|
+
return 0
|
|
1111
|
+
|
|
1112
|
+
# `connect` (SUI-02) — universal per-client wirer. Dispatched BEFORE
|
|
1113
|
+
# BrainCore construction, same reasoning as `doctor`/`mcp-config`: it
|
|
1114
|
+
# never touches the index/key/vault at all, only user config files +
|
|
1115
|
+
# (claude-code only) the `claude` CLI. Already refused on role=vm at the
|
|
1116
|
+
# VM_ALLOWED gate above, before this line is ever reached.
|
|
1117
|
+
if cmd == "connect":
|
|
1118
|
+
return _cmd_connect(args)
|
|
1119
|
+
|
|
1120
|
+
# `update` is the UP-02 single top-level entry point: it self-executes
|
|
1121
|
+
# (never just prints instructions) and is host-broker only — it mutates
|
|
1122
|
+
# the CLI plugin store, the engine venv, and staged workspaces, none of
|
|
1123
|
+
# which the VM leg may touch.
|
|
1124
|
+
if cmd == "update":
|
|
1125
|
+
from . import update as brain_update
|
|
1126
|
+
|
|
1127
|
+
report = brain_update.run_update(
|
|
1128
|
+
marketplace_name=args.marketplace,
|
|
1129
|
+
engine_src=args.engine_src,
|
|
1130
|
+
dry_run=args.dry_run,
|
|
1131
|
+
skip_capability_probe=args.skip_capability_probe,
|
|
1132
|
+
)
|
|
1133
|
+
if args.json:
|
|
1134
|
+
_emit(report, True)
|
|
1135
|
+
else:
|
|
1136
|
+
lines = [f"brain update — {'DRY RUN — ' if args.dry_run else ''}"
|
|
1137
|
+
f"{'PASS' if report['ok'] else 'FAIL/INCOMPLETE'}", ""]
|
|
1138
|
+
for step_name, step_val in report["steps"].items():
|
|
1139
|
+
lines.append(f"[{step_name}]")
|
|
1140
|
+
lines.append(step_val if isinstance(step_val, str) else json.dumps(step_val, indent=2))
|
|
1141
|
+
lines.append("")
|
|
1142
|
+
if report.get("before_after_rendered"):
|
|
1143
|
+
lines.append(report["before_after_rendered"])
|
|
1144
|
+
lines.append("")
|
|
1145
|
+
lines.append(f"notes: {report.get('notes', '')}")
|
|
1146
|
+
if report.get("residual_human_steps"):
|
|
1147
|
+
lines.append("")
|
|
1148
|
+
lines.append("Residual human step(s):")
|
|
1149
|
+
for step in report["residual_human_steps"]:
|
|
1150
|
+
lines.append(f" - {step}")
|
|
1151
|
+
_emit(None, False, "\n".join(lines))
|
|
1152
|
+
return 0 if report["ok"] else 1
|
|
1153
|
+
|
|
1154
|
+
try:
|
|
1155
|
+
core = _make_core(args, role)
|
|
1156
|
+
except Exception as exc: # pragma: no cover - construction is cheap/stable
|
|
1157
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if getattr(args, "json", False)
|
|
1158
|
+
else f"init failed: {exc}", getattr(args, "json", False))
|
|
1159
|
+
return 3
|
|
1160
|
+
|
|
1161
|
+
if cmd in ("search", "hybrid-search"):
|
|
1162
|
+
# S02/CS-01: check BEFORE the search call — a cold-start index built
|
|
1163
|
+
# with the offline hash placeholder degrades to FTS-only inside
|
|
1164
|
+
# BrainIndex._dense_ranked (see its docstring); surface that here so
|
|
1165
|
+
# the agent/user sees WHY results look lexical-only rather than
|
|
1166
|
+
# concluding the vault is thin.
|
|
1167
|
+
embedder_pending = core.embedder_pending()
|
|
1168
|
+
hits = [h.to_dict() for h in core.hybrid_search(
|
|
1169
|
+
args.query, k=args.k, rerank=args.rerank,
|
|
1170
|
+
rerank_top=args.rerank_top, rrf_k=args.rrf_k)]
|
|
1171
|
+
surfaced, report = _filter_dicts(hits, args.max_tier)
|
|
1172
|
+
freshness = _freshness_block(core, surfaced, args.max_tier)
|
|
1173
|
+
notice = (
|
|
1174
|
+
"embedder pending — dense/semantic ranking is skipped (FTS-only "
|
|
1175
|
+
"results) until the real model is applied to this index; run "
|
|
1176
|
+
"`brain warmup` then `brain sync`." if embedder_pending else None
|
|
1177
|
+
)
|
|
1178
|
+
if args.json:
|
|
1179
|
+
payload = {"query": args.query, "rerank": args.rerank,
|
|
1180
|
+
"results": surfaced, "egress": report}
|
|
1181
|
+
if freshness:
|
|
1182
|
+
payload["freshness"] = freshness
|
|
1183
|
+
if notice:
|
|
1184
|
+
payload["embedder_notice"] = notice
|
|
1185
|
+
_emit(payload, True)
|
|
1186
|
+
else:
|
|
1187
|
+
lines = [f"[{h['source']}] {h['id']} <{h.get('type') or '?'}>"
|
|
1188
|
+
f" ({h['classification'] or 'UNLABELLED'})"
|
|
1189
|
+
f" {h.get('date') or 'undated'} {h['score']}\n {h['snippet']}"
|
|
1190
|
+
for h in surfaced]
|
|
1191
|
+
footer = _egress_footer(report)
|
|
1192
|
+
if notice:
|
|
1193
|
+
footer += f"\n-- {notice}"
|
|
1194
|
+
if freshness and freshness.get("hint"):
|
|
1195
|
+
footer += f"\n-- {freshness['hint']}"
|
|
1196
|
+
_emit(None, False, "\n".join(lines + [footer]) if lines else footer)
|
|
1197
|
+
return 0
|
|
1198
|
+
|
|
1199
|
+
if cmd == "dossier":
|
|
1200
|
+
res = core.dossier(args.query, k=args.k)
|
|
1201
|
+
decisions, drep = _filter_dicts(res["decisions"], args.max_tier)
|
|
1202
|
+
sources, srep = _filter_dicts(res["sources"], args.max_tier)
|
|
1203
|
+
report = {
|
|
1204
|
+
"total": drep["total"] + srep["total"],
|
|
1205
|
+
"surfaced": drep["surfaced"] + srep["surfaced"],
|
|
1206
|
+
"withheld": drep["withheld"] + srep["withheld"],
|
|
1207
|
+
"withheld_unlabelled_default_deny":
|
|
1208
|
+
drep["withheld_unlabelled_default_deny"]
|
|
1209
|
+
+ srep["withheld_unlabelled_default_deny"],
|
|
1210
|
+
"max_tier": args.max_tier,
|
|
1211
|
+
}
|
|
1212
|
+
if report["withheld"] > 0 and args.max_tier != cls.TIERS[-1]:
|
|
1213
|
+
report["hint"] = (
|
|
1214
|
+
f"{report['withheld']} note(s) withheld above the "
|
|
1215
|
+
f"{args.max_tier} cap — re-run with a higher --max-tier.")
|
|
1216
|
+
freshness = _freshness_block(core, decisions + sources, args.max_tier)
|
|
1217
|
+
payload = {"query": res["query"], "decisions": decisions,
|
|
1218
|
+
"sources": sources,
|
|
1219
|
+
"retired_excluded": res["retired_excluded"],
|
|
1220
|
+
"egress": report}
|
|
1221
|
+
if freshness:
|
|
1222
|
+
payload["freshness"] = freshness
|
|
1223
|
+
if args.json:
|
|
1224
|
+
_emit(payload, True)
|
|
1225
|
+
else:
|
|
1226
|
+
lines = [f"== decision layer ({len(decisions)}) =="]
|
|
1227
|
+
for h in decisions:
|
|
1228
|
+
lines.append(f" {h['id']} ({h['classification']}) {h.get('date') or 'undated'}")
|
|
1229
|
+
for x in h.get("tensions", []):
|
|
1230
|
+
lines.append(f" !! newer source post-dates this decision: "
|
|
1231
|
+
f"{x['id']} ({x['date']}) — report the tension, "
|
|
1232
|
+
f"never promote the proposal")
|
|
1233
|
+
lines.append(f"== sources under consideration ({len(sources)}) ==")
|
|
1234
|
+
lines += [f" {h['id']} <{h.get('type') or '?'}> {h.get('date') or 'undated'}"
|
|
1235
|
+
for h in sources]
|
|
1236
|
+
if res["retired_excluded"]:
|
|
1237
|
+
lines.append(f"-- {res['retired_excluded']} retired version(s) excluded")
|
|
1238
|
+
footer = _egress_footer(report)
|
|
1239
|
+
if freshness and freshness.get("hint"):
|
|
1240
|
+
footer += f"\n-- {freshness['hint']}"
|
|
1241
|
+
_emit(None, False, "\n".join(lines + [footer]))
|
|
1242
|
+
return 0
|
|
1243
|
+
|
|
1244
|
+
if cmd == "grep":
|
|
1245
|
+
items = core.grep(args.pattern, k=args.k, regex=args.regex)
|
|
1246
|
+
surfaced, report = _filter_dicts(items, args.max_tier)
|
|
1247
|
+
if args.json:
|
|
1248
|
+
_emit({"pattern": args.pattern, "results": surfaced, "egress": report}, True)
|
|
1249
|
+
else:
|
|
1250
|
+
lines = [f"{h['id']} ({h['classification'] or 'UNLABELLED'}) "
|
|
1251
|
+
f"x{h['match_count']}\n {h['snippet']}" for h in surfaced]
|
|
1252
|
+
footer = _egress_footer(report)
|
|
1253
|
+
_emit(None, False, "\n".join(lines + [footer]) if lines else footer)
|
|
1254
|
+
return 0
|
|
1255
|
+
|
|
1256
|
+
if cmd == "bases-query":
|
|
1257
|
+
filters: dict[str, str] = {}
|
|
1258
|
+
for clause in args.where:
|
|
1259
|
+
if "=" in clause:
|
|
1260
|
+
key, val = clause.split("=", 1)
|
|
1261
|
+
filters[key.strip()] = val.strip()
|
|
1262
|
+
items = core.bases_query(filters, k=args.k, latest_only=args.latest_only, as_of=args.as_of)
|
|
1263
|
+
surfaced, report = _filter_dicts(items, args.max_tier)
|
|
1264
|
+
if args.json:
|
|
1265
|
+
_emit({"filters": filters, "results": surfaced, "egress": report}, True)
|
|
1266
|
+
else:
|
|
1267
|
+
lines = [f"{h['id']} type={h.get('type','?')} ({h['classification'] or 'UNLABELLED'})"
|
|
1268
|
+
for h in surfaced]
|
|
1269
|
+
footer = _egress_footer(report)
|
|
1270
|
+
_emit(None, False, "\n".join(lines + [footer]) if lines else footer)
|
|
1271
|
+
return 0
|
|
1272
|
+
|
|
1273
|
+
if cmd == "graph-expand":
|
|
1274
|
+
res = core.graph_expand(
|
|
1275
|
+
args.seeds, depth=args.depth, k=args.k, use_ppr=not args.no_ppr,
|
|
1276
|
+
use_inferred=getattr(args, "use_inferred", False))
|
|
1277
|
+
# Egress-gate the DISCOVERY candidates: a withheld note must not leak via
|
|
1278
|
+
# the graph surface either. Filter on each candidate's classification.
|
|
1279
|
+
surfaced, report = _filter_dicts(res.get("results", []), args.max_tier)
|
|
1280
|
+
res["results"] = surfaced
|
|
1281
|
+
res["egress"] = report
|
|
1282
|
+
if args.json:
|
|
1283
|
+
_emit(res, True)
|
|
1284
|
+
else:
|
|
1285
|
+
lines = [f"[graph] {h['id']} ({h['classification'] or 'UNLABELLED'}) "
|
|
1286
|
+
f"hops={h.get('hops')} ppr={h.get('ppr')}" for h in surfaced]
|
|
1287
|
+
head = (f"-- DISCOVERY-ONLY (non-authoritative); seeds="
|
|
1288
|
+
f"{res.get('resolved_seeds')}; method={res.get('method')}")
|
|
1289
|
+
footer = _egress_footer(report)
|
|
1290
|
+
_emit(None, False, "\n".join([head] + lines + [footer]))
|
|
1291
|
+
return 0
|
|
1292
|
+
|
|
1293
|
+
if cmd in ("get", "read"):
|
|
1294
|
+
note = core.get(args.id)
|
|
1295
|
+
items = [note] if note else []
|
|
1296
|
+
surfaced, report = _filter_dicts(items, args.max_tier)
|
|
1297
|
+
if not note:
|
|
1298
|
+
_emit({"error": "not_found", "id": args.id} if args.json else f"not found: {args.id}",
|
|
1299
|
+
args.json)
|
|
1300
|
+
return 1
|
|
1301
|
+
if not surfaced:
|
|
1302
|
+
msg = {"error": "withheld_by_egress_filter", "id": args.id, "egress": report}
|
|
1303
|
+
_emit(msg if args.json else f"withheld by egress filter: {args.id} "
|
|
1304
|
+
f"(classification={note.get('classification') or 'UNLABELLED'}, "
|
|
1305
|
+
f"max-tier={args.max_tier})", args.json)
|
|
1306
|
+
return 2
|
|
1307
|
+
_emit(surfaced[0] if args.json else
|
|
1308
|
+
f"# {surfaced[0]['title']} ({surfaced[0]['classification']})\n{surfaced[0]['body']}",
|
|
1309
|
+
args.json)
|
|
1310
|
+
return 0
|
|
1311
|
+
|
|
1312
|
+
if cmd == "recent":
|
|
1313
|
+
items = core.recent(limit=args.n)
|
|
1314
|
+
surfaced, report = _filter_dicts(items, args.max_tier)
|
|
1315
|
+
if args.json:
|
|
1316
|
+
_emit({"results": surfaced, "egress": report}, True)
|
|
1317
|
+
else:
|
|
1318
|
+
lines = [f"{it['updated']} {it['id']} ({it['classification'] or 'UNLABELLED'})"
|
|
1319
|
+
for it in surfaced]
|
|
1320
|
+
lines.append(_egress_footer(report))
|
|
1321
|
+
_emit(None, False, "\n".join(lines))
|
|
1322
|
+
return 0
|
|
1323
|
+
|
|
1324
|
+
if cmd == "draft-capture":
|
|
1325
|
+
content = args.content if args.content is not None else sys.stdin.read()
|
|
1326
|
+
try:
|
|
1327
|
+
res = core.draft_capture(content, ident=args.id, is_source=args.source)
|
|
1328
|
+
except ValueError as exc: # unsafe id / traversal -> fail closed (C-1)
|
|
1329
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1330
|
+
else f"draft refused ({type(exc).__name__}): {exc}", args.json)
|
|
1331
|
+
return 3
|
|
1332
|
+
_emit(res if args.json else
|
|
1333
|
+
f"staged draft {res['id']} -> {res['draft']} "
|
|
1334
|
+
f"(signed={res['signed']}, indexed={res['indexed']}); "
|
|
1335
|
+
f"host drain will sign + index + snapshot", args.json)
|
|
1336
|
+
return 0
|
|
1337
|
+
|
|
1338
|
+
if cmd == "rebuild":
|
|
1339
|
+
try:
|
|
1340
|
+
res = core.rebuild()
|
|
1341
|
+
except Exception as exc: # H-4: no raw tracebacks from maintenance cmds
|
|
1342
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1343
|
+
else f"rebuild failed ({type(exc).__name__}): {exc}", args.json)
|
|
1344
|
+
return 3
|
|
1345
|
+
_emit(res if args.json else
|
|
1346
|
+
f"indexed {res['indexed']} notes ({res['chunks']} chunks) via "
|
|
1347
|
+
f"{res['backend']} [{res['embed_model']} d={res['embed_dim']}] -> {res['db']}",
|
|
1348
|
+
args.json)
|
|
1349
|
+
return 0
|
|
1350
|
+
|
|
1351
|
+
if cmd == "warmup":
|
|
1352
|
+
try:
|
|
1353
|
+
res = core.warmup()
|
|
1354
|
+
except Exception as exc: # H-4: no raw tracebacks from maintenance cmds
|
|
1355
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1356
|
+
else f"warmup failed ({type(exc).__name__}): {exc}", args.json)
|
|
1357
|
+
return 3
|
|
1358
|
+
_emit(res if args.json else
|
|
1359
|
+
(f"embedder {res['model_id']} already cached "
|
|
1360
|
+
if res["already_cached"] else f"downloaded embedder {res['model_id']} ")
|
|
1361
|
+
+ f"({res['elapsed_s']}s). Run `brain sync` to apply it to the index "
|
|
1362
|
+
+ "if `brain status` shows embedder: pending.",
|
|
1363
|
+
args.json)
|
|
1364
|
+
return 0
|
|
1365
|
+
|
|
1366
|
+
if cmd == "sync":
|
|
1367
|
+
try:
|
|
1368
|
+
res = core.sync(drain=not args.no_drain, publish=args.publish)
|
|
1369
|
+
except Exception as exc: # H-4
|
|
1370
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1371
|
+
else f"sync failed ({type(exc).__name__}): {exc}", args.json)
|
|
1372
|
+
return 3
|
|
1373
|
+
# C8: sync's "ingest" sub-report carries the identical promoted-note
|
|
1374
|
+
# list (with real classifications) that `brain ingest --json` already
|
|
1375
|
+
# routes through the egress gate — sync --json printed it RAW, a
|
|
1376
|
+
# second content-returning surface bypassing the single chokepoint.
|
|
1377
|
+
ingest_res = res.get("ingest") or {}
|
|
1378
|
+
if ingest_res.get("processed"):
|
|
1379
|
+
surfaced, egress_report = _filter_dicts(ingest_res["processed"], cls.DEFAULT_MAX_TIER)
|
|
1380
|
+
ingest_res["processed"] = surfaced
|
|
1381
|
+
ingest_res["egress"] = egress_report
|
|
1382
|
+
# E4: "duplicates" carries `existing_id` — a real note id (of a note
|
|
1383
|
+
# that may sit above the max tier) — so it is exactly as much a
|
|
1384
|
+
# content-returning surface as "processed" and must go through the
|
|
1385
|
+
# same gate, not leak raw.
|
|
1386
|
+
if ingest_res.get("duplicates"):
|
|
1387
|
+
dup_surfaced, dup_egress = _filter_dicts(ingest_res["duplicates"], cls.DEFAULT_MAX_TIER)
|
|
1388
|
+
ingest_res["duplicates"] = dup_surfaced
|
|
1389
|
+
ingest_res["duplicates_egress"] = dup_egress
|
|
1390
|
+
if args.json:
|
|
1391
|
+
_emit(res, True)
|
|
1392
|
+
else:
|
|
1393
|
+
d = res.get("drain", {})
|
|
1394
|
+
snap = res.get("snapshot")
|
|
1395
|
+
tail = (f"; snapshot gen {snap['generation']}" if snap else "")
|
|
1396
|
+
_emit(None, False,
|
|
1397
|
+
f"sync [{res['mode']}]: +{res.get('added',0)} ~{res.get('updated',0)} "
|
|
1398
|
+
f"-{res.get('deleted',0)} ={res.get('unchanged',0)} "
|
|
1399
|
+
f"({res['chunks']} chunks); drained {d.get('promoted',0)} "
|
|
1400
|
+
f"(skipped {d.get('skipped',0)})" + tail)
|
|
1401
|
+
return 0
|
|
1402
|
+
|
|
1403
|
+
if cmd == "snapshot":
|
|
1404
|
+
try:
|
|
1405
|
+
res = core.publish_snapshot(args.dest)
|
|
1406
|
+
except Exception as exc: # H-4
|
|
1407
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1408
|
+
else f"snapshot failed ({type(exc).__name__}): {exc}", args.json)
|
|
1409
|
+
return 3
|
|
1410
|
+
_emit(res if args.json else
|
|
1411
|
+
f"published snapshot gen {res['generation']} "
|
|
1412
|
+
f"({res['notes']} notes, {res['chunks']} chunks) -> {res['snapshot_db']}",
|
|
1413
|
+
args.json)
|
|
1414
|
+
return 0
|
|
1415
|
+
|
|
1416
|
+
if cmd == "restore-index":
|
|
1417
|
+
try:
|
|
1418
|
+
res = core.restore_index_from_snapshot(force=args.force, dry_run=args.dry_run)
|
|
1419
|
+
except Exception as exc: # H-4
|
|
1420
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1421
|
+
else f"restore-index failed ({type(exc).__name__}): {exc}", args.json)
|
|
1422
|
+
return 3
|
|
1423
|
+
if args.json:
|
|
1424
|
+
_emit(res, True)
|
|
1425
|
+
elif res.get("dry_run"):
|
|
1426
|
+
_emit(f"[dry-run] would restore {res['snapshot_notes']} notes from the snapshot "
|
|
1427
|
+
f"(live index now: {res['live_notes_before']}) — nothing written", False)
|
|
1428
|
+
else:
|
|
1429
|
+
_emit(f"restored index from snapshot: {res['live_notes_after']} notes "
|
|
1430
|
+
f"(prior index backed up at {res['backup']})", False)
|
|
1431
|
+
return 0
|
|
1432
|
+
|
|
1433
|
+
if cmd == "status":
|
|
1434
|
+
res = core.status(args.snapshot_dest)
|
|
1435
|
+
if args.json:
|
|
1436
|
+
_emit(res, True)
|
|
1437
|
+
else:
|
|
1438
|
+
ix, sn, ver = res.get("index", {}), res.get("snapshot", {}), res.get("version", {})
|
|
1439
|
+
emb = res.get("embedder", {})
|
|
1440
|
+
emb_line = f"embedder: {emb.get('state','?')} [{emb.get('model_id','?')}]"
|
|
1441
|
+
if emb.get("state") == "pending":
|
|
1442
|
+
hint = emb.get("download_size_hint")
|
|
1443
|
+
emb_line += (" — run `brain warmup`"
|
|
1444
|
+
+ (f" ({hint} download)" if hint else "")
|
|
1445
|
+
+ " then `brain sync` for real semantic search")
|
|
1446
|
+
skew_lines = []
|
|
1447
|
+
if ver.get("index_newer_than_binary"):
|
|
1448
|
+
skew_lines.append(
|
|
1449
|
+
f" WARNING: index schema_version {ver.get('index_schema_version')} > "
|
|
1450
|
+
f"binary SCHEMA_VERSION {ver.get('binary_schema_version')} — "
|
|
1451
|
+
"index was built by a newer brain; update the engine "
|
|
1452
|
+
"(or run `brain sync --rebuild` to force a downgrade)")
|
|
1453
|
+
if ver.get("snapshot_newer_than_binary"):
|
|
1454
|
+
skew_lines.append(
|
|
1455
|
+
f" WARNING: snapshot schema_version {ver.get('snapshot_schema_version')} > "
|
|
1456
|
+
f"binary SCHEMA_VERSION {ver.get('binary_schema_version')} — "
|
|
1457
|
+
"snapshot is newer than this CLI; update the engine")
|
|
1458
|
+
_emit(None, False,
|
|
1459
|
+
f"brain {ver.get('package_version','?')}\n"
|
|
1460
|
+
f"index: {ix.get('notes','?')} notes / {ix.get('chunks','?')} chunks "
|
|
1461
|
+
f"[{ix.get('embed_model','?')} d={ix.get('embed_dim','?')}]\n"
|
|
1462
|
+
f"{emb_line}\n"
|
|
1463
|
+
f"snapshot: {sn.get('snapshot','?')} "
|
|
1464
|
+
+ (f"gen {sn.get('generation')} age {sn.get('age_human')}"
|
|
1465
|
+
if sn.get('snapshot') == 'present' else '')
|
|
1466
|
+
+ ("\n" + "\n".join(skew_lines) if skew_lines else ""))
|
|
1467
|
+
return 0
|
|
1468
|
+
|
|
1469
|
+
if cmd == "project":
|
|
1470
|
+
from .projection import project_workspace
|
|
1471
|
+
|
|
1472
|
+
res = project_workspace(core.vault, args.dest, max_tier=args.max_tier).to_dict()
|
|
1473
|
+
_emit(res if args.json else
|
|
1474
|
+
f"projected {res['copied']} notes (<= {res['max_tier']}) to {res['dest']}; "
|
|
1475
|
+
f"excluded {res['excluded']} ({res['excluded_unlabelled']} unlabelled)",
|
|
1476
|
+
args.json)
|
|
1477
|
+
return 0
|
|
1478
|
+
|
|
1479
|
+
if cmd == "ingest":
|
|
1480
|
+
try:
|
|
1481
|
+
res = core.ingest_dropzone(dry_run=args.dry_run)
|
|
1482
|
+
except Exception as exc: # RoleError -> fail closed, zero side effects
|
|
1483
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1484
|
+
else f"ingest refused ({type(exc).__name__}): {exc}", args.json)
|
|
1485
|
+
return 3
|
|
1486
|
+
# Egress (ADR-0003 Ruling 8): the report lists promoted note ids +
|
|
1487
|
+
# classifications, so it joins the content-returning surface — route
|
|
1488
|
+
# the processed list through the same gate as curate/integrity.
|
|
1489
|
+
if not args.dry_run and res.get("processed"):
|
|
1490
|
+
surfaced, egress_report = _filter_dicts(
|
|
1491
|
+
res["processed"], # each entry already carries its real
|
|
1492
|
+
# promoted-note classification (pipeline.py)
|
|
1493
|
+
cls.DEFAULT_MAX_TIER,
|
|
1494
|
+
)
|
|
1495
|
+
res["processed"] = surfaced
|
|
1496
|
+
res["egress"] = egress_report
|
|
1497
|
+
# E4: "duplicates" carries `existing_id` (a real note id, possibly
|
|
1498
|
+
# above max tier) via `existing_id`/`classification` — C8 only routed
|
|
1499
|
+
# "processed" through the gate, leaving this sub-list to bypass it.
|
|
1500
|
+
if not args.dry_run and res.get("duplicates"):
|
|
1501
|
+
dup_surfaced, dup_egress = _filter_dicts(res["duplicates"], cls.DEFAULT_MAX_TIER)
|
|
1502
|
+
res["duplicates"] = dup_surfaced
|
|
1503
|
+
res["duplicates_egress"] = dup_egress
|
|
1504
|
+
if args.json:
|
|
1505
|
+
_emit(res, True)
|
|
1506
|
+
else:
|
|
1507
|
+
_emit(None, False,
|
|
1508
|
+
f"ingest [dry_run={res['dry_run']}]: "
|
|
1509
|
+
f"processed={len(res.get('processed', []))} "
|
|
1510
|
+
f"quarantined={len(res.get('quarantined', []))} "
|
|
1511
|
+
f"duplicates={len(res.get('duplicates', []))} "
|
|
1512
|
+
f"skipped={len(res.get('skipped', []))}")
|
|
1513
|
+
return 0
|
|
1514
|
+
|
|
1515
|
+
if cmd == "ingest-transcript":
|
|
1516
|
+
try:
|
|
1517
|
+
res = core.ingest_transcript(
|
|
1518
|
+
args.path, origin=args.origin, language=args.language,
|
|
1519
|
+
document_date=args.document_date, classification=args.classification,
|
|
1520
|
+
)
|
|
1521
|
+
except Exception as exc: # RoleError -> fail closed, zero side effects
|
|
1522
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1523
|
+
else f"ingest-transcript refused ({type(exc).__name__}): {exc}", args.json)
|
|
1524
|
+
return 3
|
|
1525
|
+
# Egress (ADR-0003 Ruling 8, mirrors `ingest`): a fresh promotion's
|
|
1526
|
+
# result carries a real note id + classification, so it joins the
|
|
1527
|
+
# content-returning surface even though it is a single dict, not a
|
|
1528
|
+
# list — reuse the same gate via a one-element wrap.
|
|
1529
|
+
if res.get("ok") and not res.get("duplicate") and res.get("id"):
|
|
1530
|
+
surfaced, egress_report = _filter_dicts([res], cls.DEFAULT_MAX_TIER)
|
|
1531
|
+
res = surfaced[0] if surfaced else {"withheld": True, "reason": "above max-tier"}
|
|
1532
|
+
res["egress"] = egress_report
|
|
1533
|
+
if args.json:
|
|
1534
|
+
_emit(res, True)
|
|
1535
|
+
else:
|
|
1536
|
+
if not res.get("ok"):
|
|
1537
|
+
_emit(None, False, f"ingest-transcript failed: {res.get('reason')}")
|
|
1538
|
+
elif res.get("duplicate"):
|
|
1539
|
+
_emit(None, False, f"ingest-transcript: duplicate of raw/{res.get('existing_id')}.md")
|
|
1540
|
+
else:
|
|
1541
|
+
_emit(None, False, f"ingest-transcript: {res.get('note')} (origin={args.origin})")
|
|
1542
|
+
return 0 if res.get("ok", True) else 3
|
|
1543
|
+
|
|
1544
|
+
if cmd == "write":
|
|
1545
|
+
content = args.content if args.content is not None else sys.stdin.read()
|
|
1546
|
+
try:
|
|
1547
|
+
res = core.write_note(args.relpath, content, reason=args.reason)
|
|
1548
|
+
except Exception as exc: # KeyUnavailable / ValueError -> fail closed
|
|
1549
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1550
|
+
else f"write refused ({type(exc).__name__}): {exc}", args.json)
|
|
1551
|
+
return 3
|
|
1552
|
+
_emit(res if args.json else f"wrote {res['written']} (audited)", args.json)
|
|
1553
|
+
return 0
|
|
1554
|
+
|
|
1555
|
+
if cmd == "supersede":
|
|
1556
|
+
try:
|
|
1557
|
+
res = core.supersede(args.old_id, args.new_id, reason=args.reason)
|
|
1558
|
+
except Exception as exc: # RoleError / ValueError / KeyUnavailable -> fail closed
|
|
1559
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1560
|
+
else f"supersede refused ({type(exc).__name__}): {exc}", args.json)
|
|
1561
|
+
return 3
|
|
1562
|
+
_emit(res if args.json else
|
|
1563
|
+
f"superseded {res['old_id']} -> {res['new_id']} (both sides signed)",
|
|
1564
|
+
args.json)
|
|
1565
|
+
return 0
|
|
1566
|
+
|
|
1567
|
+
if cmd == "audit-key":
|
|
1568
|
+
from . import audit
|
|
1569
|
+
try:
|
|
1570
|
+
res = audit.provision_signing_key()
|
|
1571
|
+
except Exception as exc: # KeyUnavailable -> report, don't traceback
|
|
1572
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1573
|
+
else f"audit key: unavailable ({exc})", args.json)
|
|
1574
|
+
return 1
|
|
1575
|
+
_emit(res if args.json else
|
|
1576
|
+
f"audit key: {res['status']} ({res.get('source') or res.get('store')})",
|
|
1577
|
+
args.json)
|
|
1578
|
+
return 0
|
|
1579
|
+
|
|
1580
|
+
if cmd == "verify-audit":
|
|
1581
|
+
res = core.verify_audit()
|
|
1582
|
+
_emit(res if args.json else
|
|
1583
|
+
f"audit chain: {res['status']} ({res['entries_checked']} entries, "
|
|
1584
|
+
f"{len(res['errors'])} errors)", args.json)
|
|
1585
|
+
return 0 if res["status"] in ("ok", "empty") else 1
|
|
1586
|
+
|
|
1587
|
+
if cmd == "anchor":
|
|
1588
|
+
res = core.anchor_chain(args.anchor_dir)
|
|
1589
|
+
rec = res["record"]
|
|
1590
|
+
_emit(res if args.json else
|
|
1591
|
+
f"anchored head {rec['head'][:16]}… @ {rec['entry_count']} entries "
|
|
1592
|
+
f"-> {res['anchor_log']}", args.json)
|
|
1593
|
+
return 0
|
|
1594
|
+
|
|
1595
|
+
if cmd == "verify-anchor":
|
|
1596
|
+
res = core.verify_anchor(args.anchor_dir)
|
|
1597
|
+
_emit(res if args.json else
|
|
1598
|
+
f"anchor: {res['status']} ({res['checked']} records checked, "
|
|
1599
|
+
f"{len(res['divergences'])} divergences)", args.json)
|
|
1600
|
+
return 0 if res["status"] in ("ok", "no-anchor") else 1
|
|
1601
|
+
|
|
1602
|
+
if cmd == "backup":
|
|
1603
|
+
try:
|
|
1604
|
+
res = core.backup(args.dest, encrypt=not args.no_encrypt)
|
|
1605
|
+
except Exception as exc: # EncryptionKeyUnavailable etc. -> fail closed
|
|
1606
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1607
|
+
else f"backup refused ({type(exc).__name__}): {exc}", args.json)
|
|
1608
|
+
return 3
|
|
1609
|
+
_emit(res if args.json else
|
|
1610
|
+
f"backup ({'encrypted' if res['encrypted'] else 'PLAINTEXT'}) "
|
|
1611
|
+
f"{res['files']} files -> {res['archive']} "
|
|
1612
|
+
f"(sha256 {res['plaintext_sha256'][:16]}…)", args.json)
|
|
1613
|
+
return 0
|
|
1614
|
+
|
|
1615
|
+
if cmd == "restore":
|
|
1616
|
+
try:
|
|
1617
|
+
res = core.restore(args.archive, args.dest)
|
|
1618
|
+
except Exception as exc:
|
|
1619
|
+
_emit({"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1620
|
+
else f"restore failed ({type(exc).__name__}): {exc}", args.json)
|
|
1621
|
+
return 3
|
|
1622
|
+
_emit(res if args.json else
|
|
1623
|
+
f"restored {res['files']} files -> {res['dest']} "
|
|
1624
|
+
f"(sha256 {res['plaintext_sha256'][:16]}…)", args.json)
|
|
1625
|
+
return 0
|
|
1626
|
+
|
|
1627
|
+
# -- UX layer (UX-01 / UX-02 / UX-03) ---------------------------------
|
|
1628
|
+
|
|
1629
|
+
if cmd == "capture":
|
|
1630
|
+
content = args.content if args.content is not None else sys.stdin.read()
|
|
1631
|
+
try:
|
|
1632
|
+
res = core.capture(
|
|
1633
|
+
content,
|
|
1634
|
+
note_id=args.id,
|
|
1635
|
+
note_type=args.note_type,
|
|
1636
|
+
classification=args.classification,
|
|
1637
|
+
reason=args.reason,
|
|
1638
|
+
)
|
|
1639
|
+
except Exception as exc:
|
|
1640
|
+
_emit(
|
|
1641
|
+
{"error": type(exc).__name__, "detail": str(exc)} if args.json
|
|
1642
|
+
else f"capture failed ({type(exc).__name__}): {exc}",
|
|
1643
|
+
args.json,
|
|
1644
|
+
)
|
|
1645
|
+
return 3
|
|
1646
|
+
if args.json:
|
|
1647
|
+
_emit(res, True)
|
|
1648
|
+
elif res.get("signed"):
|
|
1649
|
+
_emit(None, False,
|
|
1650
|
+
f"captured {res['id']} -> {res['path']} "
|
|
1651
|
+
f"(signed=True, indexed=True)")
|
|
1652
|
+
else:
|
|
1653
|
+
_emit(None, False,
|
|
1654
|
+
f"draft staged {res['id']} -> {res['draft']} "
|
|
1655
|
+
f"(signed=False — VM; host drain will sign + index)")
|
|
1656
|
+
return 0
|
|
1657
|
+
|
|
1658
|
+
if cmd in ("brief", "digest") and getattr(args, "html", False):
|
|
1659
|
+
# ADR-0003 Ruling c / HARDENED:codex-verify-r1: the HTML file is a NEW
|
|
1660
|
+
# file-egress surface a stdout gate doesn't cover, so it is HOST-ONLY.
|
|
1661
|
+
# Refuse BEFORE any write is attempted — the VM leg (read+draft) never
|
|
1662
|
+
# gains a filesystem write surface, even though `brief`/`digest`
|
|
1663
|
+
# text/json mode stays VM_ALLOWED.
|
|
1664
|
+
if role == config.ROLE_VM:
|
|
1665
|
+
msg = {
|
|
1666
|
+
"error": "role_forbidden", "role": role, "cmd": f"{cmd} --html",
|
|
1667
|
+
"detail": f"'{cmd} --html' writes a file — host-only; the VM leg "
|
|
1668
|
+
"is read+draft only and never gains a filesystem write surface.",
|
|
1669
|
+
}
|
|
1670
|
+
_emit(msg if args.json else
|
|
1671
|
+
f"refused: '{cmd} --html' is host-only (role=vm cannot write files). "
|
|
1672
|
+
"Run it on the host.", args.json)
|
|
1673
|
+
return 4
|
|
1674
|
+
|
|
1675
|
+
if cmd == "brief":
|
|
1676
|
+
if getattr(args, "html", False):
|
|
1677
|
+
res = core.brief_html(max_recent=args.n, drain=not args.no_drain, max_tier=args.max_tier)
|
|
1678
|
+
if args.json:
|
|
1679
|
+
_emit(res, True)
|
|
1680
|
+
else:
|
|
1681
|
+
_emit(None, False, f"brief HTML written -> {res['path']} (latest: {res['latest_path']})")
|
|
1682
|
+
return 0
|
|
1683
|
+
res = core.brief(max_recent=args.n, drain=not args.no_drain, max_tier=args.max_tier)
|
|
1684
|
+
if args.json:
|
|
1685
|
+
_emit(res, True)
|
|
1686
|
+
else:
|
|
1687
|
+
from .brief import format_brief
|
|
1688
|
+
_emit(None, False, format_brief(res))
|
|
1689
|
+
return 0
|
|
1690
|
+
|
|
1691
|
+
if cmd == "digest":
|
|
1692
|
+
if getattr(args, "html", False):
|
|
1693
|
+
res = core.digest_html(days=args.days, max_tier=args.max_tier)
|
|
1694
|
+
if args.json:
|
|
1695
|
+
_emit(res, True)
|
|
1696
|
+
else:
|
|
1697
|
+
_emit(None, False, f"digest HTML written -> {res['path']} (latest: {res['latest_path']})")
|
|
1698
|
+
return 0
|
|
1699
|
+
res = core.digest(days=args.days, max_tier=args.max_tier)
|
|
1700
|
+
if args.json:
|
|
1701
|
+
_emit(res, True)
|
|
1702
|
+
else:
|
|
1703
|
+
from .brief import format_digest
|
|
1704
|
+
_emit(None, False, format_digest(res))
|
|
1705
|
+
return 0
|
|
1706
|
+
|
|
1707
|
+
# -- maintenance rituals (CUT-03) --------------------------------------
|
|
1708
|
+
from . import maintenance as maint
|
|
1709
|
+
|
|
1710
|
+
if cmd == "check":
|
|
1711
|
+
res = core.check(dry_run=args.dry_run)
|
|
1712
|
+
if args.json:
|
|
1713
|
+
_emit(res, True)
|
|
1714
|
+
else:
|
|
1715
|
+
head = f"check [dry_run={res['dry_run']}]"
|
|
1716
|
+
_emit(None, False, head + "\n" + maint.render_outcomes_markdown(res["outcomes"]))
|
|
1717
|
+
return 0
|
|
1718
|
+
|
|
1719
|
+
if cmd == "health":
|
|
1720
|
+
res = core.health()
|
|
1721
|
+
if args.json:
|
|
1722
|
+
_emit(res, True)
|
|
1723
|
+
else:
|
|
1724
|
+
st = res.get("selftest", {})
|
|
1725
|
+
head = (f"health: probe_ok={st.get('probe_ok')} "
|
|
1726
|
+
f"backend={st.get('vector_backend')} model={st.get('embed_model')}")
|
|
1727
|
+
_emit(None, False, head + "\n" + maint.render_outcomes_markdown(res["outcomes"]))
|
|
1728
|
+
return 0
|
|
1729
|
+
|
|
1730
|
+
if cmd == "curate":
|
|
1731
|
+
res = core.curate(dry_run=args.dry_run, k=args.k)
|
|
1732
|
+
surfaced, report = _filter_dicts(res["unclassified_notes"], args.max_tier)
|
|
1733
|
+
action_required = [
|
|
1734
|
+
maint.action_required_item(
|
|
1735
|
+
f"{n['id']} has a missing/invalid classification frontmatter value",
|
|
1736
|
+
"default-deny would withhold this note (treated as MNPI) until fixed",
|
|
1737
|
+
f"add classification: <Tier> to {n['path']}'s frontmatter",
|
|
1738
|
+
n["path"],
|
|
1739
|
+
)
|
|
1740
|
+
for n in surfaced
|
|
1741
|
+
]
|
|
1742
|
+
|
|
1743
|
+
# stale wikilink targets — gate on the FROM note, and the TARGET note
|
|
1744
|
+
# too when it resolved (both must clear the cap, same discipline as
|
|
1745
|
+
# near_dup's pair gating).
|
|
1746
|
+
stale_nodes: dict[str, dict] = {}
|
|
1747
|
+
for s in res["stale_links"]:
|
|
1748
|
+
stale_nodes[s["from"]["id"]] = s["from"]
|
|
1749
|
+
if s.get("target"):
|
|
1750
|
+
stale_nodes[s["target"]["id"]] = s["target"]
|
|
1751
|
+
surfaced_stale_nodes, stale_report = _filter_dicts(list(stale_nodes.values()), args.max_tier)
|
|
1752
|
+
surfaced_stale_ids = {n["id"] for n in surfaced_stale_nodes}
|
|
1753
|
+
gated_stale = [
|
|
1754
|
+
s for s in res["stale_links"]
|
|
1755
|
+
if s["from"]["id"] in surfaced_stale_ids
|
|
1756
|
+
and (s.get("target") is None or s["target"]["id"] in surfaced_stale_ids)
|
|
1757
|
+
]
|
|
1758
|
+
action_required += [
|
|
1759
|
+
maint.action_required_item(
|
|
1760
|
+
f"{s['from']['id']} links to {s['target_text']!r} which "
|
|
1761
|
+
+ ("no longer resolves to any note" if s["reason"] == "vanished"
|
|
1762
|
+
else f"has moved to {s['target']['path']}"),
|
|
1763
|
+
"a wikilink whose target vanished or moved to archive/ leads somewhere outdated",
|
|
1764
|
+
"repoint the link, update the target, or accept it as an intentional historical reference",
|
|
1765
|
+
s["from"]["path"],
|
|
1766
|
+
)
|
|
1767
|
+
for s in gated_stale
|
|
1768
|
+
]
|
|
1769
|
+
|
|
1770
|
+
# revisit sample — informational triage list, gated the same way.
|
|
1771
|
+
surfaced_revisit, revisit_report = _filter_dicts(res["revisit_sample"], args.max_tier)
|
|
1772
|
+
|
|
1773
|
+
outcomes = maint.build_outcomes(res["auto_fixed"], action_required, [])
|
|
1774
|
+
if args.json:
|
|
1775
|
+
_emit({**res, "unclassified_notes": surfaced, "stale_links": gated_stale,
|
|
1776
|
+
"revisit_sample": surfaced_revisit, "egress": report,
|
|
1777
|
+
"stale_egress": stale_report, "revisit_egress": revisit_report,
|
|
1778
|
+
"outcomes": outcomes}, True)
|
|
1779
|
+
else:
|
|
1780
|
+
head = (f"curate [dry_run={res['dry_run']}] -- {report['surfaced']}/{report['total']} unclassified surfaced, "
|
|
1781
|
+
f"{len(gated_stale)} stale link(s), {len(surfaced_revisit)} revisit candidate(s)")
|
|
1782
|
+
_emit(None, False, head + "\n" + maint.render_outcomes_markdown(outcomes))
|
|
1783
|
+
return 0
|
|
1784
|
+
|
|
1785
|
+
if cmd == "integrity":
|
|
1786
|
+
res = core.integrity(min_score=args.min_score, k=args.k)
|
|
1787
|
+
pairs = res["near_dup_pairs"]
|
|
1788
|
+
nodes = {}
|
|
1789
|
+
for p in pairs:
|
|
1790
|
+
nodes[p["a"]["id"]] = p["a"]
|
|
1791
|
+
nodes[p["b"]["id"]] = p["b"]
|
|
1792
|
+
surfaced_nodes, report = _filter_dicts(list(nodes.values()), args.max_tier)
|
|
1793
|
+
surfaced_ids = {n["id"] for n in surfaced_nodes}
|
|
1794
|
+
gated_pairs = [p for p in pairs if p["a"]["id"] in surfaced_ids and p["b"]["id"] in surfaced_ids]
|
|
1795
|
+
action_required = [maint.action_required_item(
|
|
1796
|
+
f"{p['a']['id']} <-> {p['b']['id']} score={p['score']}",
|
|
1797
|
+
"de-dup is a human merge/keep judgment, never auto-merged",
|
|
1798
|
+
"review both notes; merge or explicitly mark distinct",
|
|
1799
|
+
f"{p['a']['path']} | {p['b']['path']}",
|
|
1800
|
+
) for p in gated_pairs]
|
|
1801
|
+
if res.get("audit_issue"):
|
|
1802
|
+
action_required.insert(0, res["audit_issue"])
|
|
1803
|
+
outcomes = maint.build_outcomes([], action_required, res["blocked"])
|
|
1804
|
+
pair_report = {"total_pairs": len(pairs), "surfaced_pairs": len(gated_pairs),
|
|
1805
|
+
"withheld_pairs": len(pairs) - len(gated_pairs), "max_tier": args.max_tier}
|
|
1806
|
+
if args.json:
|
|
1807
|
+
_emit({"ritual": "integrity", "min_score": res["min_score"],
|
|
1808
|
+
"audit": res["audit"], "near_dup_pairs": gated_pairs,
|
|
1809
|
+
"egress": pair_report, "outcomes": outcomes}, True)
|
|
1810
|
+
else:
|
|
1811
|
+
head = f"integrity -- {pair_report['surfaced_pairs']}/{pair_report['total_pairs']} near-dup pairs surfaced"
|
|
1812
|
+
_emit(None, False, head + "\n" + maint.render_outcomes_markdown(outcomes))
|
|
1813
|
+
return 0
|
|
1814
|
+
|
|
1815
|
+
if cmd == "promote-scan":
|
|
1816
|
+
res = core.promote_scan(k=args.k)
|
|
1817
|
+
surfaced, report = _filter_dicts(res["candidates"], args.max_tier)
|
|
1818
|
+
action_required = [maint.action_required_item(
|
|
1819
|
+
f"{n['id']} is an un-promoted raw/ source",
|
|
1820
|
+
"promotion is a human gate (P-10-style); never automatic",
|
|
1821
|
+
"review for promotion into a typed brain/ note (brain capture / brain write)",
|
|
1822
|
+
n["path"],
|
|
1823
|
+
) for n in surfaced]
|
|
1824
|
+
outcomes = maint.build_outcomes([], action_required, [])
|
|
1825
|
+
if args.json:
|
|
1826
|
+
_emit({"ritual": "promote-scan", "candidates": surfaced,
|
|
1827
|
+
"pending_drafts": res["pending_drafts"], "egress": report,
|
|
1828
|
+
"outcomes": outcomes}, True)
|
|
1829
|
+
else:
|
|
1830
|
+
head = (f"promote-scan -- {report['surfaced']}/{report['total']} candidates surfaced; "
|
|
1831
|
+
f"{res['pending_drafts']} pending draft(s)")
|
|
1832
|
+
_emit(None, False, head + "\n" + maint.render_outcomes_markdown(outcomes))
|
|
1833
|
+
return 0
|
|
1834
|
+
|
|
1835
|
+
if cmd == "sweep-workspace":
|
|
1836
|
+
from pathlib import Path
|
|
1837
|
+
|
|
1838
|
+
env_dirs, env_age = maint.workspace_sweep_config()
|
|
1839
|
+
dirs = [Path(d).expanduser() for d in args.dirs] if args.dirs else env_dirs
|
|
1840
|
+
age = args.age_days if args.age_days else env_age
|
|
1841
|
+
if not dirs:
|
|
1842
|
+
_emit({"error": "no_dirs",
|
|
1843
|
+
"detail": "no workspace dirs: pass --dir or set "
|
|
1844
|
+
f"${maint.WORKSPACE_SWEEP_DIRS_ENV}"} if args.json
|
|
1845
|
+
else f"no workspace dirs: pass --dir or set "
|
|
1846
|
+
f"${maint.WORKSPACE_SWEEP_DIRS_ENV}", args.json)
|
|
1847
|
+
return 2
|
|
1848
|
+
res = maint.sweep_workspace(dirs, Path(core.vault) / "inbox", age,
|
|
1849
|
+
dry_run=args.dry_run)
|
|
1850
|
+
if args.json:
|
|
1851
|
+
_emit(res, True)
|
|
1852
|
+
else:
|
|
1853
|
+
_emit(None, False,
|
|
1854
|
+
f"sweep-workspace [dry_run={res['dry_run']}] age>{res['age_days']}d: "
|
|
1855
|
+
f"{len(res['swept'])} swept, {res['skipped_active']} still active, "
|
|
1856
|
+
f"{len(res['missing_dirs'])} missing dir(s), "
|
|
1857
|
+
f"{len(res['errors'])} error(s)"
|
|
1858
|
+
+ ("\nnext: `brain sync --publish` (or the nightly) drains "
|
|
1859
|
+
"inbox/ into signed raw/ notes" if res["swept"] and not res["dry_run"] else ""))
|
|
1860
|
+
return 0
|
|
1861
|
+
|
|
1862
|
+
if cmd == "maintain":
|
|
1863
|
+
parsed_date = None
|
|
1864
|
+
if args.date:
|
|
1865
|
+
import datetime as _dt
|
|
1866
|
+
parsed_date = _dt.date.fromisoformat(args.date)
|
|
1867
|
+
res = core.maintain(dry_run=args.dry_run, today=parsed_date, min_score=args.min_score)
|
|
1868
|
+
if args.json:
|
|
1869
|
+
_emit(res, True)
|
|
1870
|
+
else:
|
|
1871
|
+
head = (f"maintain [dry_run={res['dry_run']}] {res['date']} ({res['weekday']}) "
|
|
1872
|
+
f"branches_due={res['branches_due']}")
|
|
1873
|
+
_emit(None, False, head + "\n" + maint.render_outcomes_markdown(res["outcomes"]))
|
|
1874
|
+
return 0
|
|
1875
|
+
|
|
1876
|
+
if cmd == "graphify":
|
|
1877
|
+
res = core.graphify(force=args.force, dry_run=args.dry_run,
|
|
1878
|
+
max_tier=args.max_tier, candidate_limit=args.n)
|
|
1879
|
+
if args.json:
|
|
1880
|
+
_emit(res, True)
|
|
1881
|
+
elif res.get("skipped"):
|
|
1882
|
+
_emit(None, False,
|
|
1883
|
+
f"graphify: skipped ({res['skipped']}) — generation {res.get('generation')}")
|
|
1884
|
+
elif res.get("status") in ("build_failed", "invalid_artifact"):
|
|
1885
|
+
_emit(None, False, f"graphify: {res['status']} — {res.get('error') or res.get('problems')}")
|
|
1886
|
+
else:
|
|
1887
|
+
corpus = res["corpus"]
|
|
1888
|
+
build = res["build"]
|
|
1889
|
+
lines = [
|
|
1890
|
+
f"-- DISCOVERY-ONLY (non-authoritative); generation={res.get('generation')} "
|
|
1891
|
+
f"published={res.get('published')} dry_run={res.get('dry_run', False)}",
|
|
1892
|
+
f"-- notes={corpus['note_count']} explicit={corpus['explicit_edge_count']} "
|
|
1893
|
+
f"inferred={corpus['inferred_edge_count']} duration={build['duration_seconds']}s",
|
|
1894
|
+
]
|
|
1895
|
+
for c in res.get("candidates", []):
|
|
1896
|
+
lines.append(f"[graph] {c['from']} <-> {c['to']} score={c['score']} {c.get('reason', '')}")
|
|
1897
|
+
lines.append(f"-- {res['egress']['surfaced']}/{res['egress']['total']} candidates surfaced; "
|
|
1898
|
+
f"{res['egress']['withheld']} withheld (max-tier={args.max_tier})")
|
|
1899
|
+
_emit(None, False, "\n".join(lines))
|
|
1900
|
+
return 0
|
|
1901
|
+
|
|
1902
|
+
return 2
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
if __name__ == "__main__":
|
|
1906
|
+
raise SystemExit(main())
|