brainfactory 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brainfactory/__init__.py +25 -0
- brainfactory/__main__.py +10 -0
- brainfactory/apply.py +397 -0
- brainfactory/capabilities.py +403 -0
- brainfactory/cli.py +304 -0
- brainfactory/docsmesh.py +359 -0
- brainfactory/emit.py +180 -0
- brainfactory/inspect.py +441 -0
- brainfactory/manifest.py +251 -0
- brainfactory/mcpserver.py +236 -0
- brainfactory/substitute.py +126 -0
- brainfactory/upgrade.py +281 -0
- brainfactory-0.1.0.dist-info/METADATA +63 -0
- brainfactory-0.1.0.dist-info/RECORD +17 -0
- brainfactory-0.1.0.dist-info/WHEEL +5 -0
- brainfactory-0.1.0.dist-info/entry_points.txt +2 -0
- brainfactory-0.1.0.dist-info/top_level.txt +1 -0
brainfactory/__init__.py
ADDED
|
@@ -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"
|
brainfactory/__main__.py
ADDED
brainfactory/apply.py
ADDED
|
@@ -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)
|