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/init.py
ADDED
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
"""`brain init --full` — full first-run install orchestration (INS-02 / s09).
|
|
2
|
+
|
|
3
|
+
This EXTENDS the minimal PER-02 slice (`brain init --validate-overlay`, in
|
|
4
|
+
`brain.overlay` + `brain.cli`) into the single first-run command the installer
|
|
5
|
+
(`tools/cowork_workspace_install.sh` + the Intune package, INS-01) calls last:
|
|
6
|
+
|
|
7
|
+
1. **Detect the client** from the trust role (host vs Cowork/VM).
|
|
8
|
+
2. **Set up + validate the personalization overlay** — scaffold the generic
|
|
9
|
+
`overlay/{voice,brand,keywords,people}/` layer from the shipped template
|
|
10
|
+
when a category is empty (idempotent: never clobbers filled files), then
|
|
11
|
+
run the same shape validator the `--validate-overlay` slice uses.
|
|
12
|
+
3. **Drive per-client scheduled-task registration** through the s07 registrar
|
|
13
|
+
(`scripts/register_tasks.py`):
|
|
14
|
+
- **host** → register the ONE sanctioned OS task directly (launchd /
|
|
15
|
+
Task Scheduler) via the registrar's host leg (read-only probe by
|
|
16
|
+
default; `--apply` actually invokes the idempotent installer script).
|
|
17
|
+
- **Cowork/VM** → PRINT the idempotent paste-prompt (the VM leg can
|
|
18
|
+
never write/register from its read+draft role — persistence-budget.md
|
|
19
|
+
locks the VM OS-scheduled count at 0), optionally saving it to a file.
|
|
20
|
+
|
|
21
|
+
Like `brain.overlay`, this module is **filesystem + subprocess only**: it never
|
|
22
|
+
constructs a `BrainCore` and never opens the index, so it works on a brand-new
|
|
23
|
+
install before any index exists. The `brain init` dispatch in `brain.cli` runs
|
|
24
|
+
BEFORE `BrainCore` construction for exactly this reason.
|
|
25
|
+
|
|
26
|
+
The s07 registrar is loaded by *file path* (importlib) rather than a package
|
|
27
|
+
import because `scripts/` is not part of the installed `brain` package. When the
|
|
28
|
+
registrar cannot be located (e.g. the bundled binary running far from the repo),
|
|
29
|
+
task registration degrades to a clear ``registrar_unavailable`` note rather than
|
|
30
|
+
crashing — overlay setup still completes, and the manifest path is surfaced so a
|
|
31
|
+
human can run the registrar by hand.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import datetime as _dt
|
|
36
|
+
import importlib.util
|
|
37
|
+
import os
|
|
38
|
+
import shutil
|
|
39
|
+
import subprocess
|
|
40
|
+
import sys
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
from . import config
|
|
45
|
+
from . import overlay as ov
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --------------------------------------------------------------------------
|
|
49
|
+
# repo / registrar / manifest / template discovery
|
|
50
|
+
# --------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def packaged_assets_root() -> Path | None:
|
|
53
|
+
"""The wheel-shipped scaffold/registration assets (PYP-02).
|
|
54
|
+
|
|
55
|
+
``src/brain/_assets/`` MIRRORS the repo-root layout (``AGENTS.md``,
|
|
56
|
+
``templates/``, ``overlay/template/``, ``routines/manifest.json``,
|
|
57
|
+
``scripts/register_tasks.py`` + installer scripts), so every root-relative
|
|
58
|
+
resolution below works identically against a checkout or the installed
|
|
59
|
+
wheel. Synced by ``tools/package_clients.py`` — never hand-edited.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
from importlib.resources import files
|
|
63
|
+
root = Path(str(files("brain") / "_assets"))
|
|
64
|
+
except Exception: # pragma: no cover - stdlib present on >=3.9
|
|
65
|
+
return None
|
|
66
|
+
# ponytail: zipimport-backed installs (str() not a real path) fall through
|
|
67
|
+
# to the checkout; pip installs wheels unpacked, so this is the normal path.
|
|
68
|
+
if (root / "scripts" / "register_tasks.py").is_file():
|
|
69
|
+
return root
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def discover_repo_root() -> Path | None:
|
|
74
|
+
"""Best-effort locate the scaffold/registration asset root.
|
|
75
|
+
|
|
76
|
+
Precedence: ``$BRAIN_REPO_ROOT`` (explicit override) > the wheel-shipped
|
|
77
|
+
``brain/_assets`` mirror (importlib.resources — PYP-02 resolution order:
|
|
78
|
+
package resources first) > first ancestor of this file that carries
|
|
79
|
+
``scripts/register_tasks.py`` (plain source checkout on ``sys.path``) >
|
|
80
|
+
``None``.
|
|
81
|
+
"""
|
|
82
|
+
env = os.environ.get("BRAIN_REPO_ROOT")
|
|
83
|
+
if env:
|
|
84
|
+
p = Path(env).expanduser()
|
|
85
|
+
if p.is_dir():
|
|
86
|
+
return p.resolve()
|
|
87
|
+
packaged = packaged_assets_root()
|
|
88
|
+
if packaged is not None:
|
|
89
|
+
return packaged
|
|
90
|
+
for parent in Path(__file__).resolve().parents:
|
|
91
|
+
if (parent / "scripts" / "register_tasks.py").exists():
|
|
92
|
+
return parent
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_registrar(repo_root: Path | None):
|
|
97
|
+
"""Load ``scripts/register_tasks.py`` as a module (or ``None`` if absent)."""
|
|
98
|
+
if repo_root is None:
|
|
99
|
+
return None
|
|
100
|
+
path = repo_root / "scripts" / "register_tasks.py"
|
|
101
|
+
if not path.exists():
|
|
102
|
+
return None
|
|
103
|
+
spec = importlib.util.spec_from_file_location("brain._register_tasks", path)
|
|
104
|
+
if spec is None or spec.loader is None: # pragma: no cover - defensive
|
|
105
|
+
return None
|
|
106
|
+
mod = importlib.util.module_from_spec(spec)
|
|
107
|
+
spec.loader.exec_module(mod)
|
|
108
|
+
return mod
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_manifest_path(
|
|
112
|
+
explicit: str | os.PathLike[str] | None,
|
|
113
|
+
repo_root: Path | None,
|
|
114
|
+
vault: str | os.PathLike[str] | None,
|
|
115
|
+
) -> Path | None:
|
|
116
|
+
"""Resolve the task manifest: explicit > ``$BRAIN_ROUTINES_MANIFEST`` >
|
|
117
|
+
``<vault>/.brain/routines/manifest.json`` (installer-landed) >
|
|
118
|
+
``<repo>/routines/manifest.json`` > ``None``."""
|
|
119
|
+
if explicit:
|
|
120
|
+
p = Path(explicit).expanduser()
|
|
121
|
+
return p if p.exists() else None
|
|
122
|
+
env = os.environ.get("BRAIN_ROUTINES_MANIFEST")
|
|
123
|
+
if env:
|
|
124
|
+
p = Path(env).expanduser()
|
|
125
|
+
if p.exists():
|
|
126
|
+
return p
|
|
127
|
+
try:
|
|
128
|
+
installed = config.brain_runtime_dir(vault) / "routines" / "manifest.json"
|
|
129
|
+
except Exception: # pragma: no cover - vault_root is stable
|
|
130
|
+
installed = None
|
|
131
|
+
if installed and installed.exists():
|
|
132
|
+
return installed
|
|
133
|
+
if repo_root is not None:
|
|
134
|
+
repo_manifest = repo_root / "routines" / "manifest.json"
|
|
135
|
+
if repo_manifest.exists():
|
|
136
|
+
return repo_manifest
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def resolve_template_dir(
|
|
141
|
+
explicit: str | os.PathLike[str] | None,
|
|
142
|
+
repo_root: Path | None,
|
|
143
|
+
) -> Path | None:
|
|
144
|
+
"""Resolve the overlay template dir: explicit > ``<repo>/overlay/template`` >
|
|
145
|
+
``None`` (cannot scaffold)."""
|
|
146
|
+
if explicit:
|
|
147
|
+
p = Path(explicit).expanduser()
|
|
148
|
+
return p if p.is_dir() else None
|
|
149
|
+
if repo_root is not None:
|
|
150
|
+
tmpl = repo_root / "overlay" / "template"
|
|
151
|
+
if tmpl.is_dir():
|
|
152
|
+
return tmpl
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------------
|
|
157
|
+
# overlay scaffold (idempotent — never clobbers a filled category)
|
|
158
|
+
# --------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def scaffold_overlay(overlay_dir: Path, template_dir: Path | None) -> dict[str, Any]:
|
|
161
|
+
"""Copy template files into any EMPTY overlay category. Idempotent.
|
|
162
|
+
|
|
163
|
+
A category that already has ``*.md`` files is left untouched (``skipped``);
|
|
164
|
+
an empty/missing category is filled from ``template_dir/<category>/*.md``
|
|
165
|
+
(``created``). Returns a report; ``performed`` is False when no template is
|
|
166
|
+
available (scaffolding is impossible, not an error — a user may fill the
|
|
167
|
+
overlay by hand).
|
|
168
|
+
"""
|
|
169
|
+
if template_dir is None:
|
|
170
|
+
return {"performed": False, "reason": "no template dir available",
|
|
171
|
+
"created": [], "skipped": []}
|
|
172
|
+
created: list[str] = []
|
|
173
|
+
skipped: list[str] = []
|
|
174
|
+
for cat in ov.CATEGORIES:
|
|
175
|
+
dst = overlay_dir / cat
|
|
176
|
+
existing = list(dst.glob("*.md")) if dst.is_dir() else []
|
|
177
|
+
if existing:
|
|
178
|
+
skipped.append(cat)
|
|
179
|
+
continue
|
|
180
|
+
src = template_dir / cat
|
|
181
|
+
if not src.is_dir():
|
|
182
|
+
continue
|
|
183
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
for f in sorted(src.glob("*.md")):
|
|
185
|
+
target = dst / f.name
|
|
186
|
+
if not target.exists():
|
|
187
|
+
shutil.copy2(f, target)
|
|
188
|
+
created.append(f"{cat}/{f.name}")
|
|
189
|
+
return {"performed": True, "template_dir": str(template_dir),
|
|
190
|
+
"created": created, "skipped": skipped}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --------------------------------------------------------------------------
|
|
194
|
+
# ONB-02: seed a brand-new (empty) vault with generic sample notes
|
|
195
|
+
# --------------------------------------------------------------------------
|
|
196
|
+
# Fully generic content -- zero proper nouns (the release contamination scan
|
|
197
|
+
# is a hard gate). Plain filesystem writes, same posture as scaffold_overlay
|
|
198
|
+
# above: installer scaffolding, not captured content, so it never needs to go
|
|
199
|
+
# through the audited write_note path (a hand-authored note added directly to
|
|
200
|
+
# vault/brain/ is always valid -- Markdown+YAML is the substrate's single
|
|
201
|
+
# source of truth, the index is a derived cache). Host-only: the VM leg never
|
|
202
|
+
# writes directly into vault/brain/ even out-of-band (AGENTS.md §6 write
|
|
203
|
+
# split) -- run_full_init below skips this on client == "cowork".
|
|
204
|
+
_GENERATED_BRAIN_FILENAMES = {"backlinks.md", "catalog.md"}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _existing_brain_note_count(vault: str | os.PathLike[str] | None) -> int:
|
|
208
|
+
"""Notes under ``vault/brain/`` excluding the top-level index.md and any
|
|
209
|
+
generated file (backlinks.md, catalog.md) -- the "is this vault actually
|
|
210
|
+
empty" check ``seed_sample_notes`` gates on."""
|
|
211
|
+
brain_dir = config.vault_root(vault) / "brain"
|
|
212
|
+
if not brain_dir.is_dir():
|
|
213
|
+
return 0
|
|
214
|
+
count = 0
|
|
215
|
+
for p in brain_dir.rglob("*.md"):
|
|
216
|
+
if p.name in _GENERATED_BRAIN_FILENAMES:
|
|
217
|
+
continue
|
|
218
|
+
if p.name == "index.md" and p.parent == brain_dir:
|
|
219
|
+
continue
|
|
220
|
+
count += 1
|
|
221
|
+
return count
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _sample_notes(today: str) -> dict[str, str]:
|
|
225
|
+
"""``id -> full Markdown content`` for the 3 seeded sample notes: a
|
|
226
|
+
welcome note (shape), a ``concept`` note (type + Counter-Arguments
|
|
227
|
+
section), and their wikilinked partner (the "linked pair")."""
|
|
228
|
+
return {
|
|
229
|
+
"welcome-to-your-second-brain": f"""---
|
|
230
|
+
id: welcome-to-your-second-brain
|
|
231
|
+
title: "Welcome to your second brain"
|
|
232
|
+
type: note
|
|
233
|
+
classification: Internal
|
|
234
|
+
created: {today}
|
|
235
|
+
updated: {today}
|
|
236
|
+
tags: []
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
# Welcome to your second brain
|
|
240
|
+
|
|
241
|
+
This is a sample note showing the note shape every file under `vault/brain/`
|
|
242
|
+
follows: YAML frontmatter (an `id`, a `title`, a `type`, a `classification`,
|
|
243
|
+
and `created`/`updated` dates) up top, then a Markdown body below the second
|
|
244
|
+
`---`.
|
|
245
|
+
|
|
246
|
+
Notes stay flat inside their PARA folder (`projects/`, `areas/`,
|
|
247
|
+
`resources/`, `archive/`) -- no nesting, no numbering. Structure comes from
|
|
248
|
+
**wikilinks**, not folders or tags: see [[example-linked-note]] for a small
|
|
249
|
+
worked pair, and [[example-concept]] for the `concept` note type.
|
|
250
|
+
|
|
251
|
+
Delete these three sample notes whenever you like -- they exist only to show
|
|
252
|
+
the shape before you write your own.
|
|
253
|
+
""",
|
|
254
|
+
"example-concept": f"""---
|
|
255
|
+
id: example-concept
|
|
256
|
+
title: "Example concept note"
|
|
257
|
+
type: concept
|
|
258
|
+
classification: Internal
|
|
259
|
+
created: {today}
|
|
260
|
+
updated: {today}
|
|
261
|
+
tags: []
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
# Example concept note
|
|
265
|
+
|
|
266
|
+
## Definition
|
|
267
|
+
|
|
268
|
+
A `concept` note captures one idea worth naming and reusing across other
|
|
269
|
+
notes -- a definition, a mental model, a recurring pattern.
|
|
270
|
+
|
|
271
|
+
## Context & Application
|
|
272
|
+
|
|
273
|
+
Link to a concept from wherever the idea applies, instead of re-explaining it
|
|
274
|
+
each time. See [[welcome-to-your-second-brain]] for the note-shape overview
|
|
275
|
+
this sample set demonstrates.
|
|
276
|
+
|
|
277
|
+
## Counter-Arguments
|
|
278
|
+
|
|
279
|
+
Reasons this concept might be wrong, incomplete, or context-dependent -- a
|
|
280
|
+
concept note without this section is warn-flagged by the validator as a
|
|
281
|
+
quality nudge. This sample note's own counter-argument: it isn't a real
|
|
282
|
+
concept, just a placeholder.
|
|
283
|
+
|
|
284
|
+
## Related Concepts
|
|
285
|
+
|
|
286
|
+
[[example-linked-note]]
|
|
287
|
+
|
|
288
|
+
## Sources
|
|
289
|
+
""",
|
|
290
|
+
"example-linked-note": f"""---
|
|
291
|
+
id: example-linked-note
|
|
292
|
+
title: "Example linked note"
|
|
293
|
+
type: note
|
|
294
|
+
classification: Internal
|
|
295
|
+
created: {today}
|
|
296
|
+
updated: {today}
|
|
297
|
+
tags: []
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
# Example linked note
|
|
301
|
+
|
|
302
|
+
This note and [[welcome-to-your-second-brain]] link to each other -- a small
|
|
303
|
+
worked example of the wikilink-first structure this vault uses instead of
|
|
304
|
+
folders or tags. It also links to [[example-concept]] to show a `note`
|
|
305
|
+
pointing at a `concept`.
|
|
306
|
+
""",
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _sample_index(today: str) -> str:
|
|
311
|
+
"""A minimal top-level ``brain/index.md`` -- ``tools/validate.py`` hard-
|
|
312
|
+
requires this file to exist (``vault/brain/index.md missing`` is an
|
|
313
|
+
error, not a warning), and nothing else in the install path creates it
|
|
314
|
+
for a genuinely brand-new vault, so seeding is the one place that can
|
|
315
|
+
satisfy that gate. Create-if-absent only (see ``seed_sample_notes``) --
|
|
316
|
+
never overwrites an owner's own index.md."""
|
|
317
|
+
return f"""---
|
|
318
|
+
id: index
|
|
319
|
+
title: "Index"
|
|
320
|
+
type: index
|
|
321
|
+
classification: Internal
|
|
322
|
+
created: {today}
|
|
323
|
+
updated: {today}
|
|
324
|
+
tags: []
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
# Index
|
|
328
|
+
|
|
329
|
+
Map of this vault. Start here, then follow the wikilinks.
|
|
330
|
+
|
|
331
|
+
## Sample notes
|
|
332
|
+
|
|
333
|
+
- [[welcome-to-your-second-brain]] -- the note shape (frontmatter, PARA folders, wikilinks)
|
|
334
|
+
- [[example-concept]] -- the `concept` note type
|
|
335
|
+
- [[example-linked-note]] -- a small wikilinked pair
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def seed_sample_notes(vault: str | os.PathLike[str] | None) -> dict[str, Any]:
|
|
340
|
+
"""Write the 3 sample notes into ``vault/brain/resources/`` -- ONLY when
|
|
341
|
+
the vault carries no real notes yet (idempotent: a second run against a
|
|
342
|
+
now-populated vault is always a no-op, never a clobber). Also writes a
|
|
343
|
+
minimal top-level ``brain/index.md`` create-if-absent (never overwrites
|
|
344
|
+
an existing one) so the freshly seeded vault passes
|
|
345
|
+
``tools/validate.py``'s hard ``index.md missing`` gate."""
|
|
346
|
+
v = config.vault_root(vault)
|
|
347
|
+
existing = _existing_brain_note_count(v)
|
|
348
|
+
if existing > 0:
|
|
349
|
+
return {"performed": False,
|
|
350
|
+
"reason": f"vault/brain/ already has {existing} note(s)",
|
|
351
|
+
"created": []}
|
|
352
|
+
today = _dt.date.today().isoformat()
|
|
353
|
+
brain_dir = v / "brain"
|
|
354
|
+
dest_dir = brain_dir / "resources"
|
|
355
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
356
|
+
# tools/validate.py hard-requires vault/raw/ to exist too (`vault/raw/
|
|
357
|
+
# missing` is an error) -- an empty dir is a no-op to create and nothing
|
|
358
|
+
# else in the install path creates it for a genuinely brand-new vault.
|
|
359
|
+
(v / "raw").mkdir(parents=True, exist_ok=True)
|
|
360
|
+
created: list[str] = []
|
|
361
|
+
|
|
362
|
+
index_path = brain_dir / "index.md"
|
|
363
|
+
if not index_path.exists():
|
|
364
|
+
index_path.write_text(_sample_index(today), encoding="utf-8")
|
|
365
|
+
created.append("index.md")
|
|
366
|
+
|
|
367
|
+
for note_id, content in _sample_notes(today).items():
|
|
368
|
+
path = dest_dir / f"{note_id}.md"
|
|
369
|
+
if path.exists(): # defensive: the emptiness gate above already
|
|
370
|
+
continue # implies these shouldn't exist yet
|
|
371
|
+
path.write_text(content, encoding="utf-8")
|
|
372
|
+
created.append(f"resources/{note_id}.md")
|
|
373
|
+
return {"performed": True, "created": created}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# --------------------------------------------------------------------------
|
|
377
|
+
# ONB-01: brain init --full --import-from <dir> -- guided first ingest
|
|
378
|
+
# --------------------------------------------------------------------------
|
|
379
|
+
# Stages an external folder (e.g. an existing Obsidian vault) into
|
|
380
|
+
# vault/inbox/ and drives the STANDARD host ingest drain
|
|
381
|
+
# (brain.ingest.pipeline.run_ingest via BrainCore.ingest_dropzone) -- reuses
|
|
382
|
+
# the existing pipeline verbatim, never forks it. Host-only: refused
|
|
383
|
+
# (role_forbidden) before any filesystem side effect -- ingest_dropzone would
|
|
384
|
+
# refuse a VM leg anyway (BrainCore._require_host), but the check here runs
|
|
385
|
+
# BEFORE even the read-only dry-run scan, so a VM leg never touches the
|
|
386
|
+
# import folder at all.
|
|
387
|
+
#
|
|
388
|
+
# [HARDENED:codex] import safety: realpath-resolved overlap check in BOTH
|
|
389
|
+
# directions, symlinks never followed, a dry-run manifest gate (file count +
|
|
390
|
+
# bytes + per-extension breakdown) that requires explicit confirmation before
|
|
391
|
+
# anything is staged, and a default file-count/byte-size cap.
|
|
392
|
+
DEFAULT_IMPORT_FILE_CAP = 5000
|
|
393
|
+
DEFAULT_IMPORT_BYTES_CAP = 500 * 1024 * 1024
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class ImportSafetyError(ValueError):
|
|
397
|
+
"""``--import-from`` failed a pre-flight safety check; nothing was staged."""
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _realpath(p: str | os.PathLike[str]) -> Path:
|
|
401
|
+
return Path(os.path.realpath(str(Path(p).expanduser())))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def validate_import_overlap(
|
|
405
|
+
import_dir: str | os.PathLike[str], vault: str | os.PathLike[str] | None,
|
|
406
|
+
) -> None:
|
|
407
|
+
"""Reject either direction of overlap between ``import_dir`` and the
|
|
408
|
+
resolved vault root.
|
|
409
|
+
|
|
410
|
+
- ``import_dir`` inside (or equal to) the vault: would re-ingest the
|
|
411
|
+
vault's own content (including its own ``inbox/``).
|
|
412
|
+
- the vault inside ``import_dir``: the self-copy bomb -- the moment
|
|
413
|
+
staging starts writing into ``vault/inbox/``, that new content becomes
|
|
414
|
+
part of the very traversal source being walked.
|
|
415
|
+
"""
|
|
416
|
+
imp = _realpath(import_dir)
|
|
417
|
+
vlt = _realpath(config.vault_root(vault))
|
|
418
|
+
if not imp.is_dir():
|
|
419
|
+
raise ImportSafetyError(f"--import-from {imp} is not a directory")
|
|
420
|
+
try:
|
|
421
|
+
imp.relative_to(vlt)
|
|
422
|
+
except ValueError:
|
|
423
|
+
pass
|
|
424
|
+
else:
|
|
425
|
+
raise ImportSafetyError(
|
|
426
|
+
f"--import-from {imp} is inside (or equal to) the vault {vlt}; refusing")
|
|
427
|
+
try:
|
|
428
|
+
vlt.relative_to(imp)
|
|
429
|
+
except ValueError:
|
|
430
|
+
pass
|
|
431
|
+
else:
|
|
432
|
+
raise ImportSafetyError(
|
|
433
|
+
f"the vault {vlt} is inside --import-from {imp}; refusing -- this "
|
|
434
|
+
"is the self-copy bomb (vault/inbox/ would become part of the "
|
|
435
|
+
"traversal source once staging starts writing into it)")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def scan_import_dir(import_dir: str | os.PathLike[str]) -> dict[str, Any]:
|
|
439
|
+
"""Read-only walk of ``import_dir``: never follows a symlinked file or
|
|
440
|
+
directory (HARDENED:codex). Returns a dry-run manifest -- file count,
|
|
441
|
+
total bytes, per-extension breakdown -- plus the internal file list
|
|
442
|
+
``stage_import_files`` consumes to actually copy."""
|
|
443
|
+
imp = Path(import_dir)
|
|
444
|
+
files: list[tuple[Path, int]] = []
|
|
445
|
+
total_bytes = 0
|
|
446
|
+
by_extension: dict[str, int] = {}
|
|
447
|
+
for root, dirnames, filenames in os.walk(imp, followlinks=False):
|
|
448
|
+
root_path = Path(root)
|
|
449
|
+
dirnames[:] = [d for d in dirnames if not (root_path / d).is_symlink()]
|
|
450
|
+
for name in filenames:
|
|
451
|
+
fp = root_path / name
|
|
452
|
+
if fp.is_symlink():
|
|
453
|
+
continue
|
|
454
|
+
try:
|
|
455
|
+
size = fp.stat().st_size
|
|
456
|
+
except OSError:
|
|
457
|
+
continue
|
|
458
|
+
rel = fp.relative_to(imp)
|
|
459
|
+
files.append((rel, size))
|
|
460
|
+
total_bytes += size
|
|
461
|
+
ext = fp.suffix.lower() or "(none)"
|
|
462
|
+
by_extension[ext] = by_extension.get(ext, 0) + 1
|
|
463
|
+
return {
|
|
464
|
+
"import_dir": str(imp), "file_count": len(files), "total_bytes": total_bytes,
|
|
465
|
+
"by_extension": by_extension, "_files": files,
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def check_import_caps(
|
|
470
|
+
manifest: dict[str, Any], *, force: bool,
|
|
471
|
+
file_cap: int | None = None, bytes_cap: int | None = None,
|
|
472
|
+
) -> None:
|
|
473
|
+
# Resolved from the module globals AT CALL TIME (not as bound default
|
|
474
|
+
# values) so a caller (or a test) can monkeypatch
|
|
475
|
+
# DEFAULT_IMPORT_FILE_CAP/DEFAULT_IMPORT_BYTES_CAP and have it take effect.
|
|
476
|
+
if file_cap is None:
|
|
477
|
+
file_cap = DEFAULT_IMPORT_FILE_CAP
|
|
478
|
+
if bytes_cap is None:
|
|
479
|
+
bytes_cap = DEFAULT_IMPORT_BYTES_CAP
|
|
480
|
+
if force:
|
|
481
|
+
return
|
|
482
|
+
if manifest["file_count"] > file_cap:
|
|
483
|
+
raise ImportSafetyError(
|
|
484
|
+
f"{manifest['file_count']} files exceeds the default cap ({file_cap}); "
|
|
485
|
+
"pass --import-force to override")
|
|
486
|
+
if manifest["total_bytes"] > bytes_cap:
|
|
487
|
+
raise ImportSafetyError(
|
|
488
|
+
f"{manifest['total_bytes']} bytes exceeds the default cap ({bytes_cap}); "
|
|
489
|
+
"pass --import-force to override")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def build_import_dry_run(
|
|
493
|
+
import_from: str | os.PathLike[str], vault: str | os.PathLike[str] | None,
|
|
494
|
+
*, force: bool = False,
|
|
495
|
+
) -> dict[str, Any]:
|
|
496
|
+
"""Pre-flight: overlap + symlink-safe scan + cap check. Pure read-only
|
|
497
|
+
filesystem inspection -- never stages or ingests anything."""
|
|
498
|
+
validate_import_overlap(import_from, vault)
|
|
499
|
+
manifest = scan_import_dir(import_from)
|
|
500
|
+
check_import_caps(manifest, force=force)
|
|
501
|
+
return manifest
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _flatten_relpath(rel: Path) -> str:
|
|
505
|
+
"""The ingest drain only scans the inbox ROOT (never recurses), so a
|
|
506
|
+
nested import (e.g. an Obsidian vault's subfolders) is flattened into one
|
|
507
|
+
filename per file -- joined with '__' so the original path stays visible
|
|
508
|
+
and collisions across sibling subfolders are vanishingly unlikely (the
|
|
509
|
+
dest-uniquification below is the actual guarantee)."""
|
|
510
|
+
return "__".join(rel.parts) if len(rel.parts) > 1 else rel.parts[0]
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _unique_inbox_dest(inbox: Path, name: str) -> Path:
|
|
514
|
+
stem, suffix = Path(name).stem, Path(name).suffix
|
|
515
|
+
dest = inbox / name
|
|
516
|
+
i = 0
|
|
517
|
+
while dest.exists():
|
|
518
|
+
i += 1
|
|
519
|
+
dest = inbox / f"{stem}.{i}{suffix}"
|
|
520
|
+
return dest
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def stage_import_files(
|
|
524
|
+
manifest: dict[str, Any], import_from: str | os.PathLike[str],
|
|
525
|
+
vault: str | os.PathLike[str] | None,
|
|
526
|
+
) -> list[str]:
|
|
527
|
+
"""Copy (never move) every file the dry-run manifest found into
|
|
528
|
+
``vault/inbox/``. The user's original folder is never touched."""
|
|
529
|
+
v = config.vault_root(vault)
|
|
530
|
+
imp = Path(import_from)
|
|
531
|
+
inbox = v / "inbox"
|
|
532
|
+
inbox.mkdir(parents=True, exist_ok=True)
|
|
533
|
+
staged: list[str] = []
|
|
534
|
+
for rel, _size in manifest["_files"]:
|
|
535
|
+
src = imp / rel
|
|
536
|
+
if src.is_symlink():
|
|
537
|
+
continue
|
|
538
|
+
dest = _unique_inbox_dest(inbox, _flatten_relpath(rel))
|
|
539
|
+
shutil.copy2(src, dest)
|
|
540
|
+
staged.append(dest.name)
|
|
541
|
+
return staged
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def stage_and_ingest_import(
|
|
545
|
+
import_from: str | os.PathLike[str], vault: str | os.PathLike[str] | None,
|
|
546
|
+
role: str, *, force: bool = False,
|
|
547
|
+
) -> dict[str, Any]:
|
|
548
|
+
"""Stage ``import_from`` into ``vault/inbox/`` then run the STANDARD host
|
|
549
|
+
ingest drain (``BrainCore.ingest_dropzone`` -> ``ingest.pipeline.run_ingest``).
|
|
550
|
+
|
|
551
|
+
Refused on ``role != host`` BEFORE any filesystem side effect --
|
|
552
|
+
``ingest_dropzone`` would refuse a VM leg on its own
|
|
553
|
+
(``BrainCore._require_host``), but that only fires after staging already
|
|
554
|
+
copied bytes into ``inbox/``; this check runs first so a VM leg never
|
|
555
|
+
touches the import folder or the vault at all (same fail-closed shape as
|
|
556
|
+
every other host-broker verb).
|
|
557
|
+
"""
|
|
558
|
+
if role != config.ROLE_HOST:
|
|
559
|
+
raise PermissionError(
|
|
560
|
+
f"role={role!r} may not import + ingest a folder; this is a "
|
|
561
|
+
"host-broker privilege (the VM leg is read + draft only). "
|
|
562
|
+
"Run on the host.")
|
|
563
|
+
manifest = build_import_dry_run(import_from, vault, force=force)
|
|
564
|
+
staged = stage_import_files(manifest, import_from, vault)
|
|
565
|
+
|
|
566
|
+
from .core import BrainCore
|
|
567
|
+
|
|
568
|
+
core = BrainCore(vault=vault, role=role)
|
|
569
|
+
ingest_report = core.ingest_dropzone()
|
|
570
|
+
return {
|
|
571
|
+
"import_dir": manifest["import_dir"], "file_count": manifest["file_count"],
|
|
572
|
+
"total_bytes": manifest["total_bytes"], "by_extension": manifest["by_extension"],
|
|
573
|
+
"staged": len(staged), "ingest": ingest_report,
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def render_import_dry_run(manifest: dict[str, Any]) -> str:
|
|
578
|
+
lines = [
|
|
579
|
+
f"import dry-run: {manifest['import_dir']}",
|
|
580
|
+
f" {manifest['file_count']} file(s), {manifest['total_bytes']} bytes total",
|
|
581
|
+
]
|
|
582
|
+
for ext, n in sorted(manifest["by_extension"].items()):
|
|
583
|
+
lines.append(f" {ext}: {n}")
|
|
584
|
+
lines.append("re-run with --yes to stage into vault/inbox/ and run the ingest drain")
|
|
585
|
+
return "\n".join(lines)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# --------------------------------------------------------------------------
|
|
589
|
+
# orchestration
|
|
590
|
+
# --------------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
def detect_client(role: str) -> str:
|
|
593
|
+
"""Map the trust role to a client label. VM/Cowork is the read+draft leg."""
|
|
594
|
+
return "cowork" if role == config.ROLE_VM else "host"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _register_tasks(
|
|
598
|
+
*, client: str, registrar, manifest_path: Path | None, apply: bool,
|
|
599
|
+
save_cowork_prompt: str | os.PathLike[str] | None,
|
|
600
|
+
) -> dict[str, Any]:
|
|
601
|
+
"""Drive per-client registration through the s07 registrar (or degrade)."""
|
|
602
|
+
if manifest_path is None:
|
|
603
|
+
return {"registrar": "skipped", "reason": "no task manifest found",
|
|
604
|
+
"hint": "pass --manifest <path> or land routines/manifest.json"}
|
|
605
|
+
if registrar is None:
|
|
606
|
+
return {"registrar": "unavailable",
|
|
607
|
+
"manifest": str(manifest_path),
|
|
608
|
+
"reason": "scripts/register_tasks.py not found from this install; "
|
|
609
|
+
"run it by hand against the manifest above",
|
|
610
|
+
"hint": f"python3 scripts/register_tasks.py "
|
|
611
|
+
f"--client {client} --manifest {manifest_path}"}
|
|
612
|
+
|
|
613
|
+
manifest = registrar.load_manifest(manifest_path)
|
|
614
|
+
out: dict[str, Any] = {"registrar": "available", "manifest": str(manifest_path),
|
|
615
|
+
"client": client}
|
|
616
|
+
if client == "host":
|
|
617
|
+
out["host"] = registrar.register_host_leg(manifest, apply=apply)
|
|
618
|
+
out["apply"] = apply
|
|
619
|
+
else: # cowork / vm — paste-prompt only, never host mutation
|
|
620
|
+
prompt = registrar.build_cowork_prompt(manifest)
|
|
621
|
+
out["cowork"] = {
|
|
622
|
+
"vm_eligible_tasks": [t["id"] for t in manifest["tasks"] if t.get("vm_eligible")],
|
|
623
|
+
"prompt": prompt,
|
|
624
|
+
}
|
|
625
|
+
if save_cowork_prompt:
|
|
626
|
+
dest = Path(save_cowork_prompt)
|
|
627
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
628
|
+
dest.write_text(prompt, encoding="utf-8")
|
|
629
|
+
out["cowork"]["saved_to"] = str(dest)
|
|
630
|
+
return out
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _build_index(vault: str | os.PathLike[str] | None) -> dict[str, Any]:
|
|
634
|
+
"""Build the derived index for a freshly-seeded vault via a subprocess.
|
|
635
|
+
|
|
636
|
+
ONB fix (2026-07-11): ``seed_sample_notes`` writes notes to ``vault/brain/``
|
|
637
|
+
but nothing indexed them, so `brain init --full --apply` left a vault where
|
|
638
|
+
the very first `brain search` returned zero hits (the documented "init then
|
|
639
|
+
search" quickstart was broken). We shell out to `brain rebuild` rather than
|
|
640
|
+
constructing a ``BrainCore`` here so this module stays index-free and light
|
|
641
|
+
(importing BrainCore would pull the embedder into every `brain init`, even a
|
|
642
|
+
dry-run/scaffold) — matching the module's "filesystem + subprocess only"
|
|
643
|
+
contract. Invoked via ``python -m brain`` (see ``brain/__main__.py``) so it
|
|
644
|
+
is PATH-independent. Soft-fails: a rebuild error is reported, never aborts
|
|
645
|
+
init (a box without the embedder can still scaffold; the user reruns
|
|
646
|
+
`brain rebuild` once the engine is whole).
|
|
647
|
+
"""
|
|
648
|
+
argv = [sys.executable, "-m", "brain"]
|
|
649
|
+
if vault is not None:
|
|
650
|
+
argv += ["--vault", str(vault)]
|
|
651
|
+
argv.append("rebuild")
|
|
652
|
+
try:
|
|
653
|
+
proc = subprocess.run(argv, capture_output=True, text=True, timeout=600)
|
|
654
|
+
except Exception as exc: # subprocess spawn failure, timeout, etc.
|
|
655
|
+
return {"performed": False, "ok": False,
|
|
656
|
+
"reason": f"{type(exc).__name__}: {exc}"}
|
|
657
|
+
if proc.returncode == 0:
|
|
658
|
+
return {"performed": True, "ok": True}
|
|
659
|
+
return {"performed": True, "ok": False,
|
|
660
|
+
"reason": f"rebuild exit {proc.returncode}",
|
|
661
|
+
"stderr": (proc.stderr or "").strip()[-500:]}
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def run_full_init(
|
|
665
|
+
*,
|
|
666
|
+
vault: str | os.PathLike[str] | None,
|
|
667
|
+
overlay_dir: str | os.PathLike[str] | None,
|
|
668
|
+
role: str,
|
|
669
|
+
scaffold: bool = True,
|
|
670
|
+
template_dir: str | os.PathLike[str] | None = None,
|
|
671
|
+
register_tasks: bool = True,
|
|
672
|
+
apply: bool = False,
|
|
673
|
+
manifest: str | os.PathLike[str] | None = None,
|
|
674
|
+
save_cowork_prompt: str | os.PathLike[str] | None = None,
|
|
675
|
+
seed_vault: bool = True,
|
|
676
|
+
) -> dict[str, Any]:
|
|
677
|
+
"""Full `brain init` orchestration. Filesystem + subprocess only.
|
|
678
|
+
|
|
679
|
+
Returns a report dict with an ``ok`` verdict (overlay valid AND no hard
|
|
680
|
+
task-registration failure). Never raises on a malformed overlay or a missing
|
|
681
|
+
manifest — those surface as ``ok: false`` / a task note.
|
|
682
|
+
"""
|
|
683
|
+
repo_root = discover_repo_root()
|
|
684
|
+
client = detect_client(role)
|
|
685
|
+
ol_dir = ov.overlay_dir(vault, overlay_dir)
|
|
686
|
+
tmpl = resolve_template_dir(template_dir, repo_root)
|
|
687
|
+
|
|
688
|
+
steps: list[str] = [f"client detected: {client} (role={role})"]
|
|
689
|
+
|
|
690
|
+
# ONB-02: seed 2-3 generic sample notes on a genuinely EMPTY vault, host
|
|
691
|
+
# only (the VM leg never writes directly into vault/brain/ -- AGENTS.md
|
|
692
|
+
# §6). Runs before the overlay scaffold; order between the two doesn't
|
|
693
|
+
# matter, both are idempotent filesystem-only steps.
|
|
694
|
+
if seed_vault and client == "host":
|
|
695
|
+
seed_report = seed_sample_notes(vault)
|
|
696
|
+
else:
|
|
697
|
+
seed_report = {"performed": False,
|
|
698
|
+
"reason": "disabled (--no-seed-vault)" if not seed_vault
|
|
699
|
+
else "seeding is host-only (vm role never writes "
|
|
700
|
+
"directly into vault/brain/)",
|
|
701
|
+
"created": []}
|
|
702
|
+
if seed_report["performed"]:
|
|
703
|
+
steps.append(f"vault seed: wrote {len(seed_report['created'])} sample note(s)")
|
|
704
|
+
else:
|
|
705
|
+
steps.append(f"vault seed: skipped ({seed_report['reason']})")
|
|
706
|
+
|
|
707
|
+
# ONB fix: a freshly SEEDED vault must be indexed on a real `--apply`
|
|
708
|
+
# install, or the first `brain search` returns nothing (the documented
|
|
709
|
+
# "init --apply then search" quickstart). Gated on --apply + host + seed
|
|
710
|
+
# actually performed: --apply is the "really install this" signal (a bare
|
|
711
|
+
# `brain init --full` stays a lighter scaffold — its docs carry an explicit
|
|
712
|
+
# `brain rebuild`), a dry-run builds no index, and a non-empty vault (seed
|
|
713
|
+
# skipped) keeps its own index rather than eating a surprise full re-embed
|
|
714
|
+
# on a re-run. Index build is a subprocess (keeps this module BrainCore-free)
|
|
715
|
+
# and soft-fails — a box without the embedder still inits, with a note to
|
|
716
|
+
# rerun `brain rebuild`.
|
|
717
|
+
if apply and client == "host" and seed_report["performed"]:
|
|
718
|
+
index_report = _build_index(vault)
|
|
719
|
+
if index_report["ok"]:
|
|
720
|
+
steps.append("index build: rebuilt (seeded notes are searchable)")
|
|
721
|
+
else:
|
|
722
|
+
steps.append(f"index build: FAILED ({index_report['reason']}) "
|
|
723
|
+
"— run `brain rebuild` once the engine is available")
|
|
724
|
+
else:
|
|
725
|
+
index_report = {"performed": False,
|
|
726
|
+
"reason": "no seeded notes to index" if apply
|
|
727
|
+
else "dry-run (no --apply)"}
|
|
728
|
+
|
|
729
|
+
overlay_report: dict[str, Any] = {"overlay_dir": str(ol_dir)}
|
|
730
|
+
if scaffold:
|
|
731
|
+
sc = scaffold_overlay(ol_dir, tmpl)
|
|
732
|
+
overlay_report["scaffold"] = sc
|
|
733
|
+
if sc["performed"]:
|
|
734
|
+
steps.append(
|
|
735
|
+
f"overlay scaffold: created {len(sc['created'])} file(s), "
|
|
736
|
+
f"skipped {len(sc['skipped'])} filled category(ies)")
|
|
737
|
+
else:
|
|
738
|
+
steps.append(f"overlay scaffold: skipped ({sc.get('reason')})")
|
|
739
|
+
else:
|
|
740
|
+
overlay_report["scaffold"] = {"performed": False, "reason": "disabled (--no-scaffold-overlay)",
|
|
741
|
+
"created": [], "skipped": []}
|
|
742
|
+
steps.append("overlay scaffold: disabled")
|
|
743
|
+
|
|
744
|
+
validation = ov.validate_overlay(ol_dir)
|
|
745
|
+
overlay_report["validation"] = validation
|
|
746
|
+
steps.append(f"overlay validation: {'valid' if validation['valid'] else 'INVALID'}")
|
|
747
|
+
|
|
748
|
+
# Provision the audit signing key BEFORE task registration so the nightly
|
|
749
|
+
# plist render resolves a real key instead of MISSING_KEY_DRAIN_WILL_SKIP.
|
|
750
|
+
# Create-if-absent only — provision_signing_key() never rotates. Host-only;
|
|
751
|
+
# soft-fails (reported, never aborts init) so a storeless CI box still inits.
|
|
752
|
+
key_report: dict[str, Any]
|
|
753
|
+
if client == "host" and apply:
|
|
754
|
+
from . import audit
|
|
755
|
+
try:
|
|
756
|
+
key_report = audit.provision_signing_key()
|
|
757
|
+
except Exception as exc:
|
|
758
|
+
key_report = {"status": "unavailable", "error": str(exc)}
|
|
759
|
+
steps.append(f"audit key: {key_report['status']}")
|
|
760
|
+
else:
|
|
761
|
+
key_report = {"status": "skipped (vm role or dry-run)"}
|
|
762
|
+
|
|
763
|
+
tasks_report: dict[str, Any]
|
|
764
|
+
if register_tasks:
|
|
765
|
+
manifest_path = resolve_manifest_path(manifest, repo_root, vault)
|
|
766
|
+
registrar = load_registrar(repo_root)
|
|
767
|
+
tasks_report = _register_tasks(
|
|
768
|
+
client=client, registrar=registrar, manifest_path=manifest_path,
|
|
769
|
+
apply=apply, save_cowork_prompt=save_cowork_prompt)
|
|
770
|
+
steps.append(f"task registration ({client}): registrar={tasks_report.get('registrar')}")
|
|
771
|
+
else:
|
|
772
|
+
tasks_report = {"registrar": "disabled"}
|
|
773
|
+
steps.append("task registration: disabled (--no-register-tasks)")
|
|
774
|
+
|
|
775
|
+
# `ok` is driven by overlay validity + a HARD task failure only. Registrar
|
|
776
|
+
# "unavailable"/"skipped" is a SOFT degradation (the report carries a hint to
|
|
777
|
+
# finish registration by hand) — the common case for the bundled binary
|
|
778
|
+
# running far from the repo, and NOT a reason to fail the whole install.
|
|
779
|
+
host_leg = tasks_report.get("host") or {}
|
|
780
|
+
apply_result = host_leg.get("apply_result")
|
|
781
|
+
task_hard_fail = (
|
|
782
|
+
isinstance(apply_result, dict) and apply_result.get("exit_code") not in (0, None)
|
|
783
|
+
)
|
|
784
|
+
ok = bool(validation["valid"]) and not task_hard_fail
|
|
785
|
+
|
|
786
|
+
return {
|
|
787
|
+
"action": "init-full",
|
|
788
|
+
"ok": ok,
|
|
789
|
+
"client": client,
|
|
790
|
+
"role": role,
|
|
791
|
+
"repo_root": str(repo_root) if repo_root else None,
|
|
792
|
+
"seed": seed_report,
|
|
793
|
+
"index": index_report,
|
|
794
|
+
"overlay": overlay_report,
|
|
795
|
+
"audit_key": key_report,
|
|
796
|
+
"tasks": tasks_report,
|
|
797
|
+
"steps": steps,
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def render_human(report: dict[str, Any]) -> str:
|
|
802
|
+
"""Compact human rendering of a run_full_init report."""
|
|
803
|
+
lines = [
|
|
804
|
+
f"brain init (full) — client={report['client']} role={report['role']}",
|
|
805
|
+
f"ok: {report['ok']}",
|
|
806
|
+
"",
|
|
807
|
+
]
|
|
808
|
+
seed = report.get("seed") or {}
|
|
809
|
+
if seed.get("performed"):
|
|
810
|
+
lines.append(f"seed: wrote {len(seed['created'])} sample note(s)")
|
|
811
|
+
for c in seed["created"]:
|
|
812
|
+
lines.append(f" + {c}")
|
|
813
|
+
else:
|
|
814
|
+
lines.append(f"seed: not performed ({seed.get('reason')})")
|
|
815
|
+
imp = report.get("import")
|
|
816
|
+
if imp:
|
|
817
|
+
lines.append("")
|
|
818
|
+
lines.append(f"import: {imp['import_dir']}")
|
|
819
|
+
lines.append(f" staged {imp['staged']}/{imp['file_count']} file(s), "
|
|
820
|
+
f"{imp['total_bytes']} bytes")
|
|
821
|
+
ing = imp.get("ingest", {})
|
|
822
|
+
lines.append(f" ingest: {len(ing.get('processed', []))} processed, "
|
|
823
|
+
f"{len(ing.get('duplicates', []))} duplicate(s), "
|
|
824
|
+
f"{len(ing.get('quarantined', []))} quarantined")
|
|
825
|
+
lines.append("")
|
|
826
|
+
lines.append(f"overlay: {report['overlay']['overlay_dir']}")
|
|
827
|
+
sc = report["overlay"].get("scaffold", {})
|
|
828
|
+
if sc.get("performed"):
|
|
829
|
+
lines.append(f" scaffold: +{len(sc['created'])} created, "
|
|
830
|
+
f"{len(sc['skipped'])} category(ies) already filled")
|
|
831
|
+
for c in sc["created"]:
|
|
832
|
+
lines.append(f" + {c}")
|
|
833
|
+
else:
|
|
834
|
+
lines.append(f" scaffold: not performed ({sc.get('reason')})")
|
|
835
|
+
val = report["overlay"]["validation"]
|
|
836
|
+
lines.append(f" valid: {val['valid']}")
|
|
837
|
+
for cat, info in val["categories"].items():
|
|
838
|
+
status = "ok" if not info["issues"] else "ISSUES"
|
|
839
|
+
lines.append(f" {cat}/: {status} ({info['file_count']} file(s))")
|
|
840
|
+
for issue in info["issues"]:
|
|
841
|
+
lines.append(f" - {issue}")
|
|
842
|
+
|
|
843
|
+
t = report["tasks"]
|
|
844
|
+
lines.append("")
|
|
845
|
+
lines.append(f"tasks: registrar={t.get('registrar')}")
|
|
846
|
+
if t.get("manifest"):
|
|
847
|
+
lines.append(f" manifest: {t['manifest']}")
|
|
848
|
+
if "host" in t:
|
|
849
|
+
h = t["host"]
|
|
850
|
+
lines.append(f" host leg ({h.get('detected_os')}): task={h.get('task_id')} "
|
|
851
|
+
f"action={h.get('action')} apply={t.get('apply')}")
|
|
852
|
+
lines.append(f" already_registered: {h.get('already_registered')}")
|
|
853
|
+
lines.append(f" result: {h.get('apply_result')}")
|
|
854
|
+
s = h.get("synthesis")
|
|
855
|
+
if s:
|
|
856
|
+
lines.append(f" host leg task 2/2: task={s.get('task_id')}")
|
|
857
|
+
lines.append(f" already_registered: {s.get('already_registered')} "
|
|
858
|
+
f"({s.get('probe_detail')})")
|
|
859
|
+
if "cowork" in t:
|
|
860
|
+
c = t["cowork"]
|
|
861
|
+
lines.append(f" cowork leg: {len(c['vm_eligible_tasks'])} poke-only "
|
|
862
|
+
f"trigger(s) to register: {', '.join(c['vm_eligible_tasks'])}")
|
|
863
|
+
if c.get("saved_to"):
|
|
864
|
+
lines.append(f" paste-prompt saved to: {c['saved_to']}")
|
|
865
|
+
else:
|
|
866
|
+
lines.append(" (paste-prompt in the --json report; re-run with "
|
|
867
|
+
"--save-cowork-prompt <path> to write it out)")
|
|
868
|
+
if t.get("hint"):
|
|
869
|
+
lines.append(f" hint: {t['hint']}")
|
|
870
|
+
return "\n".join(lines)
|