brainfactory 0.1.0__tar.gz

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.
Files changed (29) hide show
  1. brainfactory-0.1.0/PKG-INFO +63 -0
  2. brainfactory-0.1.0/README.md +41 -0
  3. brainfactory-0.1.0/brainfactory/__init__.py +25 -0
  4. brainfactory-0.1.0/brainfactory/__main__.py +10 -0
  5. brainfactory-0.1.0/brainfactory/apply.py +397 -0
  6. brainfactory-0.1.0/brainfactory/capabilities.py +403 -0
  7. brainfactory-0.1.0/brainfactory/cli.py +304 -0
  8. brainfactory-0.1.0/brainfactory/docsmesh.py +359 -0
  9. brainfactory-0.1.0/brainfactory/emit.py +180 -0
  10. brainfactory-0.1.0/brainfactory/inspect.py +441 -0
  11. brainfactory-0.1.0/brainfactory/manifest.py +251 -0
  12. brainfactory-0.1.0/brainfactory/mcpserver.py +236 -0
  13. brainfactory-0.1.0/brainfactory/substitute.py +126 -0
  14. brainfactory-0.1.0/brainfactory/upgrade.py +281 -0
  15. brainfactory-0.1.0/brainfactory.egg-info/PKG-INFO +63 -0
  16. brainfactory-0.1.0/brainfactory.egg-info/SOURCES.txt +27 -0
  17. brainfactory-0.1.0/brainfactory.egg-info/dependency_links.txt +1 -0
  18. brainfactory-0.1.0/brainfactory.egg-info/entry_points.txt +2 -0
  19. brainfactory-0.1.0/brainfactory.egg-info/requires.txt +3 -0
  20. brainfactory-0.1.0/brainfactory.egg-info/top_level.txt +1 -0
  21. brainfactory-0.1.0/pyproject.toml +43 -0
  22. brainfactory-0.1.0/setup.cfg +4 -0
  23. brainfactory-0.1.0/tests/test_adapters.py +145 -0
  24. brainfactory-0.1.0/tests/test_apply.py +48 -0
  25. brainfactory-0.1.0/tests/test_emit.py +77 -0
  26. brainfactory-0.1.0/tests/test_manifest.py +56 -0
  27. brainfactory-0.1.0/tests/test_mcpserver.py +60 -0
  28. brainfactory-0.1.0/tests/test_substitute.py +49 -0
  29. brainfactory-0.1.0/tests/test_upgrade.py +150 -0
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: brainfactory
3
+ Version: 0.1.0
4
+ Summary: The executable onboarding engine for Brain Factory — provision, adopt, and upgrade project 'brain' repositories.
5
+ Author: Izak Laubscher
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/izakl/brainforge
8
+ Project-URL: Documentation, https://izakl.github.io/brainforge/
9
+ Project-URL: Source, https://github.com/izakl/brainforge
10
+ Keywords: brain-factory,onboarding,agents,automation,scaffolding
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Software Development :: Build Tools
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Provides-Extra: schema
21
+ Requires-Dist: jsonschema>=4; extra == "schema"
22
+
23
+ # brainfactory
24
+
25
+ The executable onboarding engine for [Brain Factory](https://github.com/izakl/brainforge)
26
+ — a single, standard-library-only Python package that provisions, adopts, and
27
+ upgrades project "brain" repositories, and backs the cross-platform adapter seam
28
+ (`bash`/`powershell` wrappers shell out to this).
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ # Isolated CLI (recommended) — installs the `brainfactory` command:
34
+ pipx install brainfactory
35
+
36
+ # Or with pip:
37
+ pip install brainfactory
38
+
39
+ # Stricter manifest validation (optional): pulls in jsonschema
40
+ pipx install "brainfactory[schema]"
41
+ ```
42
+
43
+ Until the package is published to PyPI, install straight from the repo:
44
+
45
+ ```bash
46
+ pipx install "git+https://github.com/izakl/brainforge#subdirectory=brain-factory/adapters/python"
47
+ ```
48
+
49
+ ## Use
50
+
51
+ ```bash
52
+ brainfactory --help
53
+ brainfactory inspect --repo . # read-only gap report
54
+ brainfactory provision --dest ./brain --name "Acme" --slug acme \
55
+ --brain-repo acme/acme-autonomy-system --prefix ac
56
+ brainfactory upgrade --brain ./brain # down-sync (dry-run; --apply to write)
57
+ ```
58
+
59
+ `brainfactory <cmd>` is identical to `python -m brainfactory <cmd>`; the
60
+ `bash`/`powershell` adapters and the `run.sh`/`run.ps1` dispatchers call the same
61
+ CLI, so behaviour matches across runtimes.
62
+
63
+ Requires Python 3.10+. Licensed MIT.
@@ -0,0 +1,41 @@
1
+ # brainfactory
2
+
3
+ The executable onboarding engine for [Brain Factory](https://github.com/izakl/brainforge)
4
+ — a single, standard-library-only Python package that provisions, adopts, and
5
+ upgrades project "brain" repositories, and backs the cross-platform adapter seam
6
+ (`bash`/`powershell` wrappers shell out to this).
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ # Isolated CLI (recommended) — installs the `brainfactory` command:
12
+ pipx install brainfactory
13
+
14
+ # Or with pip:
15
+ pip install brainfactory
16
+
17
+ # Stricter manifest validation (optional): pulls in jsonschema
18
+ pipx install "brainfactory[schema]"
19
+ ```
20
+
21
+ Until the package is published to PyPI, install straight from the repo:
22
+
23
+ ```bash
24
+ pipx install "git+https://github.com/izakl/brainforge#subdirectory=brain-factory/adapters/python"
25
+ ```
26
+
27
+ ## Use
28
+
29
+ ```bash
30
+ brainfactory --help
31
+ brainfactory inspect --repo . # read-only gap report
32
+ brainfactory provision --dest ./brain --name "Acme" --slug acme \
33
+ --brain-repo acme/acme-autonomy-system --prefix ac
34
+ brainfactory upgrade --brain ./brain # down-sync (dry-run; --apply to write)
35
+ ```
36
+
37
+ `brainfactory <cmd>` is identical to `python -m brainfactory <cmd>`; the
38
+ `bash`/`powershell` adapters and the `run.sh`/`run.ps1` dispatchers call the same
39
+ CLI, so behaviour matches across runtimes.
40
+
41
+ Requires Python 3.10+. Licensed MIT.
@@ -0,0 +1,25 @@
1
+ """brainfactory — the executable onboarding engine for the brain-factory.
2
+
3
+ This package is the cross-platform source of truth for onboarding a project
4
+ brain. It provides:
5
+
6
+ - substitute : placeholder substitution + template-tree instantiation
7
+ - manifest : build and validate a brain.manifest.json
8
+ - inspect : the read-only repo inspector (Mode B, inspect-first)
9
+ - apply : the applier (provision-new / adopt-existing)
10
+ - cli : the argparse command-line interface
11
+
12
+ The bash/ and powershell/ wrappers in the parent directory are thin shells over
13
+ ``python -m brainfactory`` so behaviour stays identical across runtimes.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ __all__ = [
19
+ "substitute",
20
+ "manifest",
21
+ "inspect",
22
+ "apply",
23
+ ]
24
+
25
+ __version__ = "0.1.0"
@@ -0,0 +1,10 @@
1
+ """Entry point so ``python -m brainfactory ...`` works."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,397 @@
1
+ """The applier — provision-new and adopt-existing.
2
+
3
+ provision:
4
+ Instantiate the brain-template into a NEW/empty destination directory,
5
+ substitute placeholders, strip ``.tmpl``, and write a brain.manifest.json
6
+ with ``onboarding.mode = provision-new``.
7
+
8
+ adopt:
9
+ Given a target repo plus an inspector summary, copy ONLY the modules whose
10
+ recommended action is ``add`` or ``augment`` into the target. This DEFAULTS
11
+ TO DRY-RUN: it prints the planned writes and changes nothing. Real writes
12
+ require an explicit ``apply=True`` flag, and existing files are never
13
+ overwritten unless ``force=True``. It writes/merges a brain.manifest.json
14
+ with ``onboarding.mode = adopt-existing``, recording adopted vs pre-existing
15
+ modules.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import shutil
22
+ from dataclasses import dataclass, field
23
+ from datetime import date
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from . import inspect as inspect_mod
28
+ from . import manifest as manifest_mod
29
+ from .substitute import copy_tree_substituted, strip_tmpl_suffix, substitute
30
+
31
+ # Map each core module id to the template paths (relative to brain-template/)
32
+ # that materialise it. Used by adopt to copy only the relevant subtrees.
33
+ MODULE_TEMPLATE_PATHS: dict[str, tuple[str, ...]] = {
34
+ "operating-contract": (
35
+ "00-governance/OPERATING-CONTRACT.md.tmpl",
36
+ "AGENTS.md.tmpl",
37
+ ),
38
+ "session-ritual-hooks": ("03-templates/agent-commands/hooks",),
39
+ "continuity-log": (
40
+ "04-policies/continuity-policy.md.tmpl",
41
+ "05-logs/00-MASTER-SESSION-INDEX.md.tmpl",
42
+ ),
43
+ "capabilities-map": ("01-docs/CAPABILITIES.md.tmpl",),
44
+ "docs-mesh": ("01-docs/diagrams",),
45
+ "decision-board": ("00-governance/consensus/decision-board.md.tmpl",),
46
+ "core-commands": ("03-templates/agent-commands/core",),
47
+ }
48
+
49
+
50
+ def _hub_root() -> Path:
51
+ return Path(__file__).resolve().parents[3]
52
+
53
+
54
+ def template_root(hub_root: Path | None = None) -> Path:
55
+ return (Path(hub_root) if hub_root else _hub_root()) / "brain-template"
56
+
57
+
58
+ @dataclass
59
+ class PlannedWrite:
60
+ """A single file the applier would write."""
61
+
62
+ module: str
63
+ source: str # relative to brain-template
64
+ dest: str # relative to destination
65
+ exists: bool
66
+
67
+ def to_dict(self) -> dict[str, Any]:
68
+ return {
69
+ "module": self.module,
70
+ "source": self.source,
71
+ "dest": self.dest,
72
+ "exists": self.exists,
73
+ }
74
+
75
+
76
+ @dataclass
77
+ class ApplyResult:
78
+ mode: str
79
+ destination: str
80
+ dry_run: bool
81
+ planned: list[PlannedWrite] = field(default_factory=list)
82
+ written: list[str] = field(default_factory=list)
83
+ skipped: list[str] = field(default_factory=list)
84
+ manifest_path: str | None = None
85
+ notes: list[str] = field(default_factory=list)
86
+
87
+ def to_dict(self) -> dict[str, Any]:
88
+ return {
89
+ "mode": self.mode,
90
+ "destination": self.destination,
91
+ "dry_run": self.dry_run,
92
+ "planned": [p.to_dict() for p in self.planned],
93
+ "written": self.written,
94
+ "skipped": self.skipped,
95
+ "manifest_path": self.manifest_path,
96
+ "notes": self.notes,
97
+ }
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # provision
102
+ # ---------------------------------------------------------------------------
103
+
104
+ def provision(
105
+ *,
106
+ dest: str | Path,
107
+ project_name: str,
108
+ project_slug: str,
109
+ brain_repo: str,
110
+ command_prefix: str,
111
+ platforms: list[str] | None = None,
112
+ profile: str | None = None,
113
+ summary: str | None = None,
114
+ app_repos: list[dict[str, Any]] | None = None,
115
+ hub_root: Path | None = None,
116
+ ) -> ApplyResult:
117
+ """Instantiate the brain-template into an empty ``dest`` directory."""
118
+ dest_path = Path(dest).resolve()
119
+ if dest_path.exists() and any(dest_path.iterdir()):
120
+ raise FileExistsError(
121
+ f"provision requires an empty/new destination; {dest_path} is not empty")
122
+ dest_path.mkdir(parents=True, exist_ok=True)
123
+
124
+ platforms = platforms or ["bash", "powershell", "python"]
125
+ mapping = {
126
+ "PROJECT_NAME": project_name,
127
+ "PROJECT_SLUG": project_slug,
128
+ "BRAIN_REPO": brain_repo,
129
+ "CMD_PREFIX": command_prefix,
130
+ }
131
+
132
+ tmpl = template_root(hub_root)
133
+ # The template's own README and example manifest are documentation *about* the
134
+ # template, not part of a brain instance, so exclude them. brain.manifest.schema.json
135
+ # is kept (copied verbatim) as a validation reference. A real manifest is stamped below.
136
+ written_rel = copy_tree_substituted(
137
+ tmpl, dest_path, mapping,
138
+ exclude=frozenset({"README.md", "brain.manifest.example.json"}),
139
+ )
140
+ man = manifest_mod.build_manifest(
141
+ project_name=project_name,
142
+ project_slug=project_slug,
143
+ brain_repo=brain_repo,
144
+ command_prefix=command_prefix,
145
+ platforms=platforms,
146
+ mode="provision-new",
147
+ profile=profile,
148
+ summary=summary,
149
+ app_repos=app_repos,
150
+ onboarded_at=date.today().isoformat(),
151
+ hub_root=hub_root,
152
+ )
153
+ errors = manifest_mod.validate_manifest(man, hub_root)
154
+ if errors:
155
+ raise ValueError("Built manifest failed validation:\n - "
156
+ + "\n - ".join(errors))
157
+ manifest_path = dest_path / "brain.manifest.json"
158
+ manifest_mod.write_manifest(man, manifest_path)
159
+
160
+ result = ApplyResult(
161
+ mode="provision-new",
162
+ destination=str(dest_path),
163
+ dry_run=False,
164
+ written=[str(p) for p in written_rel] + ["brain.manifest.json"],
165
+ manifest_path=str(manifest_path),
166
+ )
167
+ result.notes.append(
168
+ f"Instantiated {len(written_rel)} template files; wrote brain.manifest.json.")
169
+ return result
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # adopt
174
+ # ---------------------------------------------------------------------------
175
+
176
+ def _plan_module_writes(
177
+ module: str, tmpl: Path, target: Path, mapping: dict[str, str],
178
+ ) -> list[PlannedWrite]:
179
+ """Compute the planned writes for one module's template subtree."""
180
+ planned: list[PlannedWrite] = []
181
+ for rel in MODULE_TEMPLATE_PATHS.get(module, ()): # noqa: B007
182
+ src = tmpl / rel
183
+ if not src.exists():
184
+ continue
185
+ if src.is_dir():
186
+ for entry in sorted(src.rglob("*")):
187
+ if entry.is_dir():
188
+ continue
189
+ rel_from_tmpl = entry.relative_to(tmpl)
190
+ dest_rel = _dest_for(rel_from_tmpl)
191
+ planned.append(PlannedWrite(
192
+ module=module,
193
+ source=str(rel_from_tmpl).replace("\\", "/"),
194
+ dest=str(dest_rel).replace("\\", "/"),
195
+ exists=(target / dest_rel).exists(),
196
+ ))
197
+ else:
198
+ rel_from_tmpl = src.relative_to(tmpl)
199
+ dest_rel = _dest_for(rel_from_tmpl)
200
+ planned.append(PlannedWrite(
201
+ module=module,
202
+ source=str(rel_from_tmpl).replace("\\", "/"),
203
+ dest=str(dest_rel).replace("\\", "/"),
204
+ exists=(target / dest_rel).exists(),
205
+ ))
206
+ return planned
207
+
208
+
209
+ def _dest_for(rel_from_tmpl: Path) -> Path:
210
+ """Destination path for a template file (strip .tmpl on the final name)."""
211
+ return rel_from_tmpl.with_name(strip_tmpl_suffix(rel_from_tmpl.name))
212
+
213
+
214
+ def adopt(
215
+ *,
216
+ target: str | Path,
217
+ summary: dict[str, Any] | inspect_mod.InspectionResult | None = None,
218
+ project_name: str | None = None,
219
+ project_slug: str | None = None,
220
+ brain_repo: str | None = None,
221
+ command_prefix: str | None = None,
222
+ platforms: list[str] | None = None,
223
+ profile: str | None = None,
224
+ apply: bool = False,
225
+ force: bool = False,
226
+ gap_report: str | None = None,
227
+ hub_root: Path | None = None,
228
+ ) -> ApplyResult:
229
+ """Adopt the brain into an existing repo. Defaults to DRY-RUN.
230
+
231
+ Only modules whose inspector action is ``add`` or ``augment`` are copied.
232
+ Modules that are ``keep`` (present) are recorded in the manifest as
233
+ pre-existing (``adopted: false``).
234
+ """
235
+ target_path = Path(target).resolve()
236
+ if not target_path.is_dir():
237
+ raise NotADirectoryError(f"Target repo not found: {target_path}")
238
+
239
+ # Obtain an inspection summary: use the provided one, else run the inspector.
240
+ if summary is None:
241
+ result = inspect_mod.inspect_repo(target_path)
242
+ summary_dict = result.to_dict()
243
+ elif isinstance(summary, inspect_mod.InspectionResult):
244
+ summary_dict = summary.to_dict()
245
+ else:
246
+ summary_dict = summary
247
+
248
+ by_module: dict[str, dict[str, Any]] = {
249
+ m["module"]: m for m in summary_dict.get("modules", [])
250
+ }
251
+
252
+ # Defaults inferred from the target if not supplied.
253
+ slug = project_slug or target_path.name.replace("_", "-")
254
+ name = project_name or target_path.name.replace("_", " ").title()
255
+ repo = brain_repo or f"izakl/{slug}-autonomy-system"
256
+ prefix = command_prefix or _default_prefix(slug)
257
+ plats = platforms or summary_dict.get(
258
+ "platform_signals", {}).get("inferred_platforms", ["python"])
259
+
260
+ mapping = {
261
+ "PROJECT_NAME": name,
262
+ "PROJECT_SLUG": slug,
263
+ "BRAIN_REPO": repo,
264
+ "CMD_PREFIX": prefix,
265
+ }
266
+
267
+ tmpl = template_root(hub_root)
268
+ planned: list[PlannedWrite] = []
269
+ for module in manifest_mod.CORE_MODULE_IDS:
270
+ finding = by_module.get(module, {})
271
+ action = finding.get("action", "add")
272
+ if action in ("add", "augment"):
273
+ planned.extend(_plan_module_writes(module, tmpl, target_path, mapping))
274
+
275
+ result = ApplyResult(
276
+ mode="adopt-existing",
277
+ destination=str(target_path),
278
+ dry_run=not apply,
279
+ planned=planned,
280
+ )
281
+
282
+ # Build the manifest reflecting adopted (hub-installed) vs pre-existing.
283
+ core_modules: dict[str, dict[str, Any]] = {}
284
+ for module in manifest_mod.CORE_MODULE_IDS:
285
+ finding = by_module.get(module, {})
286
+ action = finding.get("action", "add")
287
+ # 'keep' => pre-existing, project-owned (adopted=false).
288
+ # 'add'/'augment' => hub-installed by this adopt (adopted=true).
289
+ adopted = action != "keep"
290
+ core_modules[module] = {
291
+ "enabled": True,
292
+ "synced_from": manifest_mod.read_framework_version(hub_root),
293
+ "adopted": adopted,
294
+ }
295
+
296
+ man = manifest_mod.build_manifest(
297
+ project_name=name,
298
+ project_slug=slug,
299
+ brain_repo=repo,
300
+ command_prefix=prefix,
301
+ platforms=plats,
302
+ mode="adopt-existing",
303
+ profile=profile,
304
+ core_modules=core_modules,
305
+ onboarded_at=date.today().isoformat(),
306
+ gap_report=gap_report,
307
+ hub_root=hub_root,
308
+ )
309
+ man_errors = manifest_mod.validate_manifest(man, hub_root)
310
+ if man_errors:
311
+ result.notes.append("WARNING: built manifest failed validation: "
312
+ + "; ".join(man_errors))
313
+
314
+ if not apply:
315
+ result.notes.append(
316
+ f"DRY-RUN: {len(planned)} file(s) would be written. "
317
+ "Re-run with --apply to write. No files were modified.")
318
+ result.notes.append(
319
+ "Manifest (mode=adopt-existing) would be written to "
320
+ "brain.manifest.json on apply.")
321
+ # Surface a preview of the manifest's adopted/pre-existing split.
322
+ kept = [m for m, e in core_modules.items() if not e["adopted"]]
323
+ added = [m for m, e in core_modules.items() if e["adopted"]]
324
+ result.notes.append(f"Manifest split — keep (pre-existing): "
325
+ f"{', '.join(kept) or 'none'}")
326
+ result.notes.append(f"Manifest split — add/augment (hub-owned): "
327
+ f"{', '.join(added) or 'none'}")
328
+ return result
329
+
330
+ # --- real apply ---
331
+ for pw in planned:
332
+ dest_abs = target_path / pw.dest
333
+ src_abs = tmpl / pw.source
334
+ if dest_abs.exists() and not force:
335
+ result.skipped.append(pw.dest)
336
+ continue
337
+ dest_abs.parent.mkdir(parents=True, exist_ok=True)
338
+ if pw.source.endswith(".tmpl"):
339
+ content = src_abs.read_text(encoding="utf-8")
340
+ dest_abs.write_text(substitute(content, mapping), encoding="utf-8")
341
+ else:
342
+ shutil.copyfile(src_abs, dest_abs)
343
+ result.written.append(pw.dest)
344
+
345
+ manifest_path = target_path / "brain.manifest.json"
346
+ if manifest_path.exists() and not force:
347
+ result.notes.append(
348
+ "brain.manifest.json already exists; left untouched (use --force to "
349
+ "overwrite). New manifest NOT written.")
350
+ else:
351
+ manifest_mod.write_manifest(man, manifest_path)
352
+ result.manifest_path = str(manifest_path)
353
+ result.written.append("brain.manifest.json")
354
+
355
+ result.notes.append(
356
+ f"Applied: {len(result.written)} written, {len(result.skipped)} skipped "
357
+ "(existing, not forced).")
358
+ return result
359
+
360
+
361
+ def _default_prefix(slug: str) -> str:
362
+ """Derive a short command prefix from a slug (best-effort)."""
363
+ letters = [c for c in slug if c.isalnum()]
364
+ if not letters:
365
+ return "br"
366
+ # Use the first two alphanumerics, ensuring it starts with a letter.
367
+ cand = "".join(letters[:2]).lower()
368
+ if not cand[0].isalpha():
369
+ cand = "b" + cand[0]
370
+ return cand[:8]
371
+
372
+
373
+ def render_apply_text(result: ApplyResult) -> str:
374
+ """Render a human-readable apply/adopt plan or report."""
375
+ lines: list[str] = []
376
+ header = "DRY-RUN PLAN" if result.dry_run else "APPLY REPORT"
377
+ lines.append(f"=== {header}: {result.mode} -> {result.destination} ===")
378
+ lines.append("")
379
+ if result.planned:
380
+ lines.append("Planned writes:")
381
+ for pw in result.planned:
382
+ flag = " (exists, would skip unless --force)" if pw.exists else ""
383
+ lines.append(f" [{pw.module}] {pw.dest}{flag}")
384
+ lines.append("")
385
+ if result.written:
386
+ lines.append("Written:")
387
+ for w in result.written:
388
+ lines.append(f" + {w}")
389
+ lines.append("")
390
+ if result.skipped:
391
+ lines.append("Skipped (already present):")
392
+ for s in result.skipped:
393
+ lines.append(f" = {s}")
394
+ lines.append("")
395
+ for note in result.notes:
396
+ lines.append(note)
397
+ return "\n".join(lines)