brain-wiki 0.1.0.dev0__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.
Files changed (141) hide show
  1. brain_wiki-0.1.0.dev0.dist-info/METADATA +231 -0
  2. brain_wiki-0.1.0.dev0.dist-info/RECORD +141 -0
  3. brain_wiki-0.1.0.dev0.dist-info/WHEEL +4 -0
  4. brain_wiki-0.1.0.dev0.dist-info/entry_points.txt +4 -0
  5. brain_wiki-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  6. brainwiki/__init__.py +17 -0
  7. brainwiki/__main__.py +5 -0
  8. brainwiki/assets/plugin/.claude-plugin/marketplace.json +16 -0
  9. brainwiki/assets/plugin/.claude-plugin/plugin.json +19 -0
  10. brainwiki/assets/plugin/.mcp.json +16 -0
  11. brainwiki/assets/plugin/mcp.json +17 -0
  12. brainwiki/assets/plugin/mcp_config.json +15 -0
  13. brainwiki/assets/plugin/plugin.json +37 -0
  14. brainwiki/assets/plugin/rules/brain-wiki.md +30 -0
  15. brainwiki/assets/plugin/server.json +30 -0
  16. brainwiki/assets/plugin/skills/brain/SKILL.md +145 -0
  17. brainwiki/assets/plugin/skills/brain/references/format.md +149 -0
  18. brainwiki/assets/plugin/skills/brain-maintain/SKILL.md +111 -0
  19. brainwiki/assets/plugin/skills/brain-maintain/references/consolidate.md +91 -0
  20. brainwiki/assets/plugin/skills/brain-maintain/references/harvest.md +144 -0
  21. brainwiki/assets/plugin/skills/brain-setup/SKILL.md +184 -0
  22. brainwiki/assets/plugin/skills/brain-setup/references/hosts.md +88 -0
  23. brainwiki/assets/plugin/skills/brain-setup/references/privacy.md +67 -0
  24. brainwiki/assets/prompts/consolidate.md +89 -0
  25. brainwiki/assets/prompts/distill.md +96 -0
  26. brainwiki/assets/prompts/harvest.md +142 -0
  27. brainwiki/bench/__init__.py +8 -0
  28. brainwiki/bench/_generate.py +440 -0
  29. brainwiki/bench/_report.py +166 -0
  30. brainwiki/bench/_suites.py +959 -0
  31. brainwiki/cli/__init__.py +7 -0
  32. brainwiki/cli/_cmd_bench.py +101 -0
  33. brainwiki/cli/_cmd_browse.py +281 -0
  34. brainwiki/cli/_cmd_consolidate.py +108 -0
  35. brainwiki/cli/_cmd_core.py +155 -0
  36. brainwiki/cli/_cmd_harvest.py +137 -0
  37. brainwiki/cli/_cmd_hosts.py +52 -0
  38. brainwiki/cli/_cmd_import.py +149 -0
  39. brainwiki/cli/_cmd_lint.py +117 -0
  40. brainwiki/cli/_cmd_read.py +131 -0
  41. brainwiki/cli/_cmd_runs.py +66 -0
  42. brainwiki/cli/_cmd_viz.py +57 -0
  43. brainwiki/cli/_cmd_write.py +122 -0
  44. brainwiki/cli/_main.py +511 -0
  45. brainwiki/cli/_output.py +19 -0
  46. brainwiki/config/__init__.py +56 -0
  47. brainwiki/config/_dirs.py +69 -0
  48. brainwiki/config/_manifest.py +307 -0
  49. brainwiki/config/_resolve.py +169 -0
  50. brainwiki/config/_user.py +183 -0
  51. brainwiki/consolidate/__init__.py +19 -0
  52. brainwiki/consolidate/_apply.py +408 -0
  53. brainwiki/consolidate/_packet.py +489 -0
  54. brainwiki/consolidate/_pipeline.py +215 -0
  55. brainwiki/consolidate/_presets.py +230 -0
  56. brainwiki/consolidate/_review.py +104 -0
  57. brainwiki/consolidate/_schema.py +153 -0
  58. brainwiki/consolidate/_validate.py +252 -0
  59. brainwiki/doctor.py +216 -0
  60. brainwiki/engine.py +80 -0
  61. brainwiki/errors.py +81 -0
  62. brainwiki/format/__init__.py +112 -0
  63. brainwiki/format/_agents_md.py +26 -0
  64. brainwiki/format/_claims.py +95 -0
  65. brainwiki/format/_document.py +203 -0
  66. brainwiki/format/_edit.py +94 -0
  67. brainwiki/format/_episode.py +67 -0
  68. brainwiki/format/_fields.py +350 -0
  69. brainwiki/format/_frontmatter.py +175 -0
  70. brainwiki/format/_links.py +72 -0
  71. brainwiki/format/_meta.py +67 -0
  72. brainwiki/format/_normalize.py +70 -0
  73. brainwiki/format/_relations.py +211 -0
  74. brainwiki/format/_rules.py +41 -0
  75. brainwiki/harvest.py +414 -0
  76. brainwiki/hosts/__init__.py +22 -0
  77. brainwiki/hosts/_antigravity.py +163 -0
  78. brainwiki/hosts/_claude_code.py +174 -0
  79. brainwiki/hosts/_codex.py +149 -0
  80. brainwiki/hosts/_connect.py +443 -0
  81. brainwiki/hosts/_detect.py +39 -0
  82. brainwiki/hosts/_instructions.py +220 -0
  83. brainwiki/hosts/_sessions.py +176 -0
  84. brainwiki/ids.py +69 -0
  85. brainwiki/importer/__init__.py +17 -0
  86. brainwiki/importer/_episodes.py +112 -0
  87. brainwiki/importer/_into.py +102 -0
  88. brainwiki/importer/_normalize.py +64 -0
  89. brainwiki/importer/_overlay.py +199 -0
  90. brainwiki/importer/_survey.py +167 -0
  91. brainwiki/index/__init__.py +44 -0
  92. brainwiki/index/_brains.py +119 -0
  93. brainwiki/index/_db.py +131 -0
  94. brainwiki/index/_guards.py +154 -0
  95. brainwiki/index/_neighbors.py +174 -0
  96. brainwiki/index/_orient.py +400 -0
  97. brainwiki/index/_read.py +285 -0
  98. brainwiki/index/_resolver.py +147 -0
  99. brainwiki/index/_scanner.py +665 -0
  100. brainwiki/index/_schema.py +164 -0
  101. brainwiki/index/_search.py +659 -0
  102. brainwiki/index/_topics.py +253 -0
  103. brainwiki/index/_written.py +29 -0
  104. brainwiki/lifecycle.py +158 -0
  105. brainwiki/lint/__init__.py +20 -0
  106. brainwiki/lint/_catalog.py +85 -0
  107. brainwiki/lint/_cross.py +649 -0
  108. brainwiki/lint/_files.py +483 -0
  109. brainwiki/lint/_fixes.py +87 -0
  110. brainwiki/lint/_hook.py +54 -0
  111. brainwiki/lint/_model.py +103 -0
  112. brainwiki/lint/_reports.py +242 -0
  113. brainwiki/lint/_runner.py +156 -0
  114. brainwiki/logs.py +34 -0
  115. brainwiki/mcp/__init__.py +7 -0
  116. brainwiki/mcp/_server.py +917 -0
  117. brainwiki/origin.py +97 -0
  118. brainwiki/runs/__init__.py +21 -0
  119. brainwiki/runs/_git.py +89 -0
  120. brainwiki/runs/_lock.py +42 -0
  121. brainwiki/runs/_record.py +145 -0
  122. brainwiki/runs/_transaction.py +441 -0
  123. brainwiki/runs/_undo.py +159 -0
  124. brainwiki/setup_wizard.py +143 -0
  125. brainwiki/store/__init__.py +7 -0
  126. brainwiki/store/_local.py +153 -0
  127. brainwiki/viz/__init__.py +9 -0
  128. brainwiki/viz/_export.py +414 -0
  129. brainwiki/viz/_lod.py +455 -0
  130. brainwiki/viz/_server.py +327 -0
  131. brainwiki/viz/app/BUILD_INFO.json +5 -0
  132. brainwiki/viz/app/THIRD_PARTY_NOTICES.txt +1519 -0
  133. brainwiki/viz/app/assets/index-gK1pnQdO.js +4496 -0
  134. brainwiki/viz/app/assets/style-Cg8NUCst.css +1 -0
  135. brainwiki/viz/app/index.html +14 -0
  136. brainwiki/write/__init__.py +23 -0
  137. brainwiki/write/_edits.py +730 -0
  138. brainwiki/write/_episode.py +195 -0
  139. brainwiki/write/_move.py +181 -0
  140. brainwiki/write/_rules.py +204 -0
  141. brainwiki/write/_secrets.py +108 -0
@@ -0,0 +1,103 @@
1
+ """Lint problems, fixes, and results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from brainwiki.lint._catalog import fixable as catalog_fixable
10
+
11
+
12
+ @dataclass
13
+ class Fix:
14
+ description: str
15
+ edit: Callable[[str], str] | None = None # text transform of the target file
16
+ move_to: str | None = None
17
+ create: str | None = None # full content for a file that does not exist yet
18
+ target: str | None = None # file the fix changes, when it is not the problem's file
19
+
20
+
21
+ @dataclass
22
+ class Problem:
23
+ code: str
24
+ path: str
25
+ line: int | None
26
+ message: str
27
+ severity: str = ""
28
+ fix: Fix | None = None
29
+ fixable: bool = False
30
+
31
+ def __post_init__(self) -> None:
32
+ if self.fix is not None:
33
+ self.fixable = True
34
+ self.fixable = self.fixable and catalog_fixable(self.code)
35
+
36
+ def key(self) -> tuple[str, int, str]:
37
+ return (self.path, self.line or 0, self.code)
38
+
39
+ def to_json(self) -> dict[str, Any]:
40
+ return {
41
+ "code": self.code,
42
+ "severity": self.severity,
43
+ "path": self.path,
44
+ "line": self.line,
45
+ "message": self.message,
46
+ "fixable": self.fixable,
47
+ }
48
+
49
+ def __str__(self) -> str:
50
+ where = f"{self.path}:{self.line}" if self.line else self.path
51
+ return f"{where} {self.code} {self.message}"
52
+
53
+
54
+ @dataclass
55
+ class LintResult:
56
+ problems: list[Problem] = field(default_factory=list)
57
+ files_checked: int = 0
58
+ seconds: float = 0.0
59
+
60
+ def count(self, severity: str) -> int:
61
+ return sum(1 for p in self.problems if p.severity == severity)
62
+
63
+ @property
64
+ def errors(self) -> int:
65
+ return self.count("error")
66
+
67
+ @property
68
+ def fixable(self) -> int:
69
+ return sum(1 for p in self.problems if p.fixable)
70
+
71
+ def to_json(self) -> dict[str, Any]:
72
+ return {
73
+ "problems": [p.to_json() for p in self.problems],
74
+ "errors": self.errors,
75
+ "warnings": self.count("warning"),
76
+ "info": self.count("info"),
77
+ "fixable": self.fixable,
78
+ "files_checked": self.files_checked,
79
+ }
80
+
81
+ def format_text(self) -> str:
82
+ lines: list[str] = []
83
+ current = None
84
+ for p in self.problems:
85
+ if p.path != current:
86
+ lines.append(p.path)
87
+ current = p.path
88
+ line = str(p.line) if p.line else "-"
89
+ fix = " (fixable)" if p.fixable else ""
90
+ lines.append(f" {line:>4} {p.severity:<7} {p.code} {p.message}{fix}")
91
+ total = len(self.problems)
92
+ if not total:
93
+ return f"No problems ({self.files_checked} files checked)."
94
+ parts = [f"{self.errors} error{'s' if self.errors != 1 else ''}"]
95
+ warnings = self.count("warning")
96
+ parts.append(f"{warnings} warning{'s' if warnings != 1 else ''}")
97
+ if self.count("info"):
98
+ parts.append(f"{self.count('info')} info")
99
+ summary = f"{total} problem{'s' if total != 1 else ''} ({', '.join(parts)})"
100
+ if self.fixable:
101
+ summary += f", {self.fixable} fixable with --fix"
102
+ lines.append(summary)
103
+ return "\n".join(lines)
@@ -0,0 +1,242 @@
1
+ """`reports/`: index queries rendered as Markdown pages for people (design doc 06 §7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from datetime import UTC, datetime
7
+ from typing import TYPE_CHECKING
8
+
9
+ from brainwiki.ids import is_ulid, ulid_datetime
10
+ from brainwiki.lint._model import LintResult
11
+
12
+ if TYPE_CHECKING:
13
+ from brainwiki.engine import Engine
14
+
15
+ _HEADER = "<!-- Generated by brain-wiki. Do not edit: `brain report` rewrites this file. -->"
16
+ _REPORTS = (
17
+ "index",
18
+ "contradictions",
19
+ "stale",
20
+ "unverified",
21
+ "inbox",
22
+ "duplicates",
23
+ "orphans",
24
+ "lint",
25
+ )
26
+
27
+
28
+ def _link(path: str, title: str | None = None) -> str:
29
+ target = path[:-3] if path.lower().endswith(".md") else path
30
+ label = (title or "").replace("|", "/").replace("]", ")")
31
+ return f"[[{target}|{label}]]" if label and label != target else f"[[{target}]]"
32
+
33
+
34
+ def _page(title: str, intro: str, lines: list[str], empty: str) -> str:
35
+ body = lines or [empty]
36
+ return "\n".join([_HEADER, f"# {title}", "", intro, "", *body]) + "\n"
37
+
38
+
39
+ def _contradictions(engine: Engine) -> str:
40
+ seen: set[frozenset[str]] = set()
41
+ lines = []
42
+ for row in engine.db.execute(
43
+ """SELECT a.id AS a_id, a.path AS a_path, a.title AS a_title, b.id AS b_id, b.path AS b_path,
44
+ b.title AS b_title, e.qualifier, e.note, e.attrs
45
+ FROM edges e JOIN nodes a ON a.id = e.src JOIN nodes b ON b.id = e.dst
46
+ WHERE e.rel = 'conflicts_with' AND a.status = 'active' AND b.status = 'active'
47
+ ORDER BY a.path, b.path"""
48
+ ):
49
+ pair = frozenset((row["a_id"], row["b_id"]))
50
+ if pair in seen:
51
+ continue
52
+ seen.add(pair)
53
+ extra = " ".join(
54
+ bit
55
+ for bit in (
56
+ f"({row['qualifier']})" if row["qualifier"] else "",
57
+ f"— {row['note']}" if row["note"] else "",
58
+ )
59
+ if bit
60
+ )
61
+ lines.append(
62
+ f"- {_link(row['a_path'], row['a_title'])} ↔ {_link(row['b_path'], row['b_title'])} {extra}".rstrip()
63
+ )
64
+ return _page(
65
+ "Contradictions",
66
+ "Active notes that conflict with each other. Read both before acting on either.",
67
+ lines,
68
+ "No open contradictions.",
69
+ )
70
+
71
+
72
+ def _stale(engine: Engine) -> str:
73
+ lines = [
74
+ f"- {_link(r['a_path'], r['a_title'])} depends on {_link(r['b_path'], r['b_title'])} ({r['status']})"
75
+ for r in engine.db.execute(
76
+ """SELECT a.path AS a_path, a.title AS a_title, b.path AS b_path, b.title AS b_title, b.status
77
+ FROM edges e JOIN nodes a ON a.id = e.src JOIN nodes b ON b.id = e.dst
78
+ WHERE e.rel = 'depends_on' AND a.status = 'active' AND b.status IN ('superseded', 'archived')
79
+ ORDER BY a.path"""
80
+ )
81
+ ]
82
+ return _page(
83
+ "Stale dependencies",
84
+ "Active notes that depend on superseded or archived notes; they may need updating.",
85
+ lines,
86
+ "No stale dependencies.",
87
+ )
88
+
89
+
90
+ def _unverified(engine: Engine) -> str:
91
+ lines = [
92
+ f"- {_link(r['path'], r['title'])}: {r['c']} unverified claim{'s' if r['c'] != 1 else ''}"
93
+ for r in engine.db.execute(
94
+ """SELECT n.path, n.title, COUNT(*) AS c FROM claims c JOIN nodes n ON n.id = c.node_id
95
+ WHERE c.inputs IN ('external', 'unknown') AND c.status = 'active'
96
+ GROUP BY n.id ORDER BY c DESC, n.path"""
97
+ )
98
+ ]
99
+ return _page(
100
+ "Unverified claims",
101
+ "Notes with active claims derived from untrusted or unknown sources (shown as [unverified] in reads).",
102
+ lines,
103
+ "No unverified claims.",
104
+ )
105
+
106
+
107
+ def _inbox(engine: Engine) -> str:
108
+ now = datetime.now(UTC)
109
+ by_agent: dict[str, list[tuple[float, str]]] = defaultdict(list)
110
+ for row in engine.db.execute("SELECT path, fm_id, agent FROM nodes WHERE kind = 'episode'"):
111
+ age = (
112
+ (now - ulid_datetime(row["fm_id"])).total_seconds() / 86400
113
+ if is_ulid(row["fm_id"] or "")
114
+ else 0.0
115
+ )
116
+ by_agent[row["agent"] or "unknown"].append((age, row["path"]))
117
+ lines = ["## Pending episodes", ""]
118
+ if not by_agent:
119
+ lines.append("None.")
120
+ for agent, items in sorted(by_agent.items()):
121
+ items.sort(reverse=True)
122
+ lines.append(f"- **{agent}**: {len(items)} pending, oldest {items[0][0]:.0f} days")
123
+ for age, path in items[:20]:
124
+ lines.append(f" - {_link(path)} ({age:.0f} days)")
125
+ if len(items) > 20:
126
+ lines.append(f" - … {len(items) - 20} more")
127
+ lines += ["", "## Open proposals", ""]
128
+ proposals = [
129
+ r["path"]
130
+ for r in engine.db.execute("SELECT path FROM files WHERE kind = 'proposal' ORDER BY path")
131
+ ]
132
+ lines += [f"- {_link(p)}" for p in proposals] or ["None."]
133
+ return _page(
134
+ "Inbox", "Episodes waiting for consolidation, and proposals waiting for review.", lines, ""
135
+ )
136
+
137
+
138
+ def _duplicates(result: LintResult) -> str:
139
+ lines = [
140
+ f"- {_link(p.path)} line {p.line}: {p.message}"
141
+ for p in result.problems
142
+ if p.code == "BW206"
143
+ ]
144
+ return _page(
145
+ "Duplicate claims",
146
+ "Claims that cite the same source span with near-identical text, usually from consolidating twice.",
147
+ lines,
148
+ "No duplicates.",
149
+ )
150
+
151
+
152
+ def _orphans(engine: Engine) -> str:
153
+ lines = [
154
+ f"- {_link(r['path'], r['title'])}"
155
+ for r in engine.db.execute(
156
+ """SELECT n.path, n.title FROM nodes n WHERE n.kind = 'note' AND NOT EXISTS (
157
+ SELECT 1 FROM edges e WHERE e.other = n.id AND e.declared_in != n.id)
158
+ ORDER BY n.path"""
159
+ )
160
+ ]
161
+ return _page("Orphans", "Notes nothing links to and no hub lists.", lines, "No orphans.")
162
+
163
+
164
+ def _lint_page(result: LintResult) -> str:
165
+ lines = []
166
+ current = None
167
+ for p in result.problems:
168
+ if p.path != current:
169
+ lines.append(f"- {_link(p.path) if p.path.endswith('.md') else '`' + p.path + '`'}")
170
+ current = p.path
171
+ where = f"line {p.line}: " if p.line else ""
172
+ lines.append(f" - {p.severity} {p.code}: {where}{p.message}")
173
+ intro = (
174
+ f"{result.errors} errors, {result.count('warning')} warnings, {result.count('info')} info."
175
+ )
176
+ return _page("Lint", intro, lines, "No problems.")
177
+
178
+
179
+ def _index_page(engine: Engine, result: LintResult) -> str:
180
+ conn = engine.db
181
+
182
+ def count(sql: str) -> int:
183
+ return int(conn.execute(sql).fetchone()[0])
184
+
185
+ stats = [
186
+ ("Notes", count("SELECT COUNT(*) FROM nodes WHERE kind = 'note'")),
187
+ ("Hubs", count("SELECT COUNT(*) FROM nodes WHERE kind = 'hub'")),
188
+ ("Active claims", count("SELECT COUNT(*) FROM claims WHERE status = 'active'")),
189
+ ("Relations", count("SELECT COUNT(*) FROM edges WHERE rel != 'links_to'")),
190
+ ("Pending episodes", count("SELECT COUNT(*) FROM nodes WHERE kind = 'episode'")),
191
+ ("Archived episodes", count("SELECT COUNT(*) FROM nodes WHERE kind = 'archived_episode'")),
192
+ ]
193
+ last = "never"
194
+ try:
195
+ for line in engine.store.read_text("log.md").splitlines():
196
+ if line.startswith("## ") and "· consolidate ·" in line:
197
+ last = line[3:].split(" · ")[0]
198
+ except OSError:
199
+ pass
200
+ lines = [f"- {name}: {value}" for name, value in stats]
201
+ lines += [
202
+ f"- Last consolidation: {last}",
203
+ f"- Lint: {result.errors} errors, {result.count('warning')} warnings",
204
+ "",
205
+ "## Reports",
206
+ "",
207
+ ]
208
+ lines += [
209
+ f"- [[{engine.manifest.layout.reports_dir}/{name}|{name.title()}]]"
210
+ for name in _REPORTS
211
+ if name != "index"
212
+ ]
213
+ generated = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
214
+ return _page(f"Brain report: {engine.brain.display_name}", f"Generated {generated}.", lines, "")
215
+
216
+
217
+ def generate(engine: Engine, result: LintResult | None = None) -> list[str]:
218
+ """Rewrite every report whose content changed. Returns the paths written."""
219
+ from brainwiki.lint._runner import lint
220
+
221
+ result = result or lint(engine)
222
+ pages = {
223
+ "contradictions": _contradictions(engine),
224
+ "stale": _stale(engine),
225
+ "unverified": _unverified(engine),
226
+ "inbox": _inbox(engine),
227
+ "duplicates": _duplicates(result),
228
+ "orphans": _orphans(engine),
229
+ "lint": _lint_page(result),
230
+ }
231
+ written = []
232
+ directory = engine.manifest.layout.reports_dir
233
+ for name, content in pages.items():
234
+ rel = f"{directory}/{name}.md"
235
+ if engine.store.exists(rel) and engine.store.read_text(rel) == content:
236
+ continue
237
+ engine.store.write_text(rel, content)
238
+ written.append(rel)
239
+ index_rel = f"{directory}/index.md"
240
+ engine.store.write_text(index_rel, _index_page(engine, result))
241
+ written.append(index_rel)
242
+ return written
@@ -0,0 +1,156 @@
1
+ """Run lint over a brain: file rules (cached by content hash) plus cross-file rules from the index."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import time
8
+ from fnmatch import fnmatch
9
+ from typing import TYPE_CHECKING
10
+
11
+ from brainwiki.lint._catalog import severity
12
+ from brainwiki.lint._cross import cross_checks
13
+ from brainwiki.lint._files import LINT_VERSION, FileContext, check_file, check_text
14
+ from brainwiki.lint._model import LintResult, Problem
15
+
16
+ if TYPE_CHECKING:
17
+ from brainwiki.engine import Engine
18
+
19
+ _CHECKED_KINDS = (
20
+ "note",
21
+ "hub",
22
+ "episode",
23
+ "archived_episode",
24
+ "agent_file",
25
+ "rules",
26
+ "agents_md",
27
+ "proposal",
28
+ "log",
29
+ )
30
+
31
+
32
+ def _cache_prefix(engine: Engine) -> str:
33
+ budgets = json.dumps(vars(engine.manifest.budgets), sort_keys=True)
34
+ return f"{LINT_VERSION}:{hashlib.sha256(budgets.encode()).hexdigest()[:8]}:"
35
+
36
+
37
+ def _in_scope(path: str, scope: list[str] | None) -> bool:
38
+ if scope is None:
39
+ return True
40
+ for item in scope:
41
+ item = item.rstrip("/")
42
+ if item in ("", ".") or path == item or path.startswith(item + "/"):
43
+ return True
44
+ return False
45
+
46
+
47
+ def _match(code: str, patterns: list[str] | None) -> bool:
48
+ return bool(patterns) and any(code.startswith(p.strip().upper()) for p in patterns or [] if p)
49
+
50
+
51
+ def lint(
52
+ engine: Engine,
53
+ *,
54
+ paths: list[str] | None = None,
55
+ profile: str | None = None,
56
+ select: list[str] | None = None,
57
+ ignore: list[str] | None = None,
58
+ fixes: bool = False,
59
+ staged: dict[str, bytes] | None = None,
60
+ ) -> LintResult:
61
+ """Lint the brain, or only `paths`. `staged` maps paths to the bytes git would commit."""
62
+ started = time.monotonic()
63
+ engine.scan(force=True)
64
+ conn = engine.db
65
+ ctx = FileContext.for_engine(engine)
66
+ manifest = engine.manifest
67
+ scope = list(staged) if staged is not None else paths
68
+
69
+ def profile_for(path: str) -> str:
70
+ for pattern, value in manifest.lint.overrides.items():
71
+ if fnmatch(path, pattern):
72
+ return value
73
+ return profile or manifest.lint.profile
74
+
75
+ problems: list[Problem] = []
76
+ prefix = _cache_prefix(engine)
77
+ cache = {
78
+ row["path"]: (row["sha256"], row["problems"])
79
+ for row in conn.execute("SELECT path, sha256, problems FROM lint_cache")
80
+ }
81
+ updates: list[tuple[str, str, str]] = []
82
+ checked = 0
83
+ rows = conn.execute(
84
+ """SELECT f.path, f.kind, f.sha256, n.kind AS node_kind
85
+ FROM files f LEFT JOIN nodes n ON n.id = f.node_id ORDER BY f.path"""
86
+ ).fetchall()
87
+ kinds = {row["path"]: row["node_kind"] or row["kind"] for row in rows}
88
+ if staged is not None:
89
+ from brainwiki.index import classify
90
+
91
+ for path, data in staged.items():
92
+ kind = kinds.get(path) or classify(engine, path, ctx.topics)
93
+ if kind not in _CHECKED_KINDS or profile_for(path) == "off":
94
+ continue
95
+ checked += 1
96
+ problems += check_file(ctx, path, kind, data, fixes=False)
97
+ else:
98
+ for row in rows:
99
+ path = row["path"]
100
+ kind = kinds[path]
101
+ if kind not in _CHECKED_KINDS or not _in_scope(path, scope):
102
+ continue
103
+ if profile_for(path) == "off":
104
+ continue
105
+ checked += 1
106
+ key = prefix + row["sha256"]
107
+ hit = cache.get(path)
108
+ if hit and hit[0] == key and not fixes:
109
+ problems += [Problem(**p) for p in json.loads(hit[1])]
110
+ continue
111
+ try:
112
+ data = engine.store.read_bytes(path)
113
+ except OSError:
114
+ continue
115
+ found = check_file(ctx, path, kind, data, fixes=fixes)
116
+ problems += found
117
+ stored = [
118
+ {
119
+ "code": p.code,
120
+ "path": p.path,
121
+ "line": p.line,
122
+ "message": p.message,
123
+ "fixable": p.fixable,
124
+ }
125
+ for p in found
126
+ ]
127
+ updates.append((path, key, json.dumps(stored)))
128
+ if updates:
129
+ conn.execute("BEGIN IMMEDIATE")
130
+ conn.executemany(
131
+ "INSERT OR REPLACE INTO lint_cache(path, sha256, problems) VALUES (?, ?, ?)", updates
132
+ )
133
+ conn.execute("COMMIT")
134
+ problems += [p for p in cross_checks(engine, ctx, fixes=fixes) if _in_scope(p.path, scope)]
135
+
136
+ kept: list[Problem] = []
137
+ for problem in problems:
138
+ if _match(problem.code, ignore) or (select and not _match(problem.code, select)):
139
+ continue
140
+ problem.severity = severity(problem.code, profile_for(problem.path))
141
+ if problem.severity == "off":
142
+ continue
143
+ kept.append(problem)
144
+ kept.sort(key=lambda p: (p.path, p.line or 0, p.code))
145
+ return LintResult(kept, files_checked=checked, seconds=time.monotonic() - started)
146
+
147
+
148
+ def lint_text(engine: Engine, path: str, text: str) -> list[Problem]:
149
+ """File rules for one file's proposed content (strict), for previews and tests."""
150
+ from brainwiki.index import classify
151
+
152
+ ctx = FileContext.for_engine(engine)
153
+ problems = check_text(ctx, path, classify(engine, path, ctx.topics), text)
154
+ for problem in problems:
155
+ problem.severity = severity(problem.code, "strict")
156
+ return [p for p in problems if p.severity != "off"]
brainwiki/logs.py ADDED
@@ -0,0 +1,34 @@
1
+ """Local log file for long-running processes whose stdout belongs to a protocol (07 §2.2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from logging.handlers import RotatingFileHandler
7
+ from pathlib import Path
8
+
9
+ from brainwiki.config import state_dir
10
+
11
+ _configured = False
12
+
13
+
14
+ def log_path() -> Path:
15
+ return state_dir() / "logs" / "brain-wiki.log"
16
+
17
+
18
+ def setup_logging(verbose: bool = False) -> logging.Logger:
19
+ """Send brain-wiki logs to the rotating state-dir log, never to stdout."""
20
+ global _configured
21
+ logger = logging.getLogger("brainwiki")
22
+ if _configured:
23
+ return logger
24
+ path = log_path()
25
+ path.parent.mkdir(parents=True, exist_ok=True)
26
+ handler = RotatingFileHandler(path, maxBytes=2_000_000, backupCount=3, encoding="utf-8")
27
+ handler.setFormatter(
28
+ logging.Formatter("%(asctime)s %(process)d %(levelname)s %(name)s: %(message)s")
29
+ )
30
+ logger.addHandler(handler)
31
+ logger.setLevel(logging.DEBUG if verbose else logging.INFO)
32
+ logger.propagate = False
33
+ _configured = True
34
+ return logger
@@ -0,0 +1,7 @@
1
+ """The MCP server. The SDK is imported only here, never by the core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from brainwiki.mcp._server import main, serve
6
+
7
+ __all__ = ["main", "serve"]