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,483 @@
1
+ """File-local lint rules: everything decidable from one file's content plus the manifest.
2
+
3
+ Results depend only on (content, manifest), so the runner caches them by content hash.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from collections import Counter
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ from brainwiki.config import Manifest
15
+ from brainwiki.format import (
16
+ ALIAS_KEYS,
17
+ FIELDS,
18
+ INPUTS,
19
+ ORIGINS,
20
+ RELATION_PROPERTIES,
21
+ STATUSES,
22
+ ParsedDoc,
23
+ add_items,
24
+ as_items,
25
+ canonical_item,
26
+ canonicalize_line,
27
+ claim_value,
28
+ episode_path,
29
+ format_meta,
30
+ is_format1,
31
+ map_status,
32
+ mask_code,
33
+ move_item,
34
+ parse_document,
35
+ parse_rules,
36
+ property_links,
37
+ relation_values_for,
38
+ remove_field,
39
+ replace_item,
40
+ replace_line_matching,
41
+ set_fields,
42
+ to_format2,
43
+ )
44
+ from brainwiki.ids import claim_id, is_ulid, new_ulid
45
+ from brainwiki.lint._catalog import severity
46
+ from brainwiki.lint._model import Fix, Problem
47
+ from brainwiki.write import find_secrets
48
+
49
+ if TYPE_CHECKING:
50
+ from brainwiki.engine import Engine
51
+ from brainwiki.index import TopicTree
52
+ from brainwiki.runs import Run
53
+
54
+ LINT_VERSION = "1"
55
+ _NOTE_KINDS = ("note", "hub")
56
+ _EPISODE_KINDS = ("episode", "archived_episode")
57
+ _SECRET_KINDS = (
58
+ "note",
59
+ "hub",
60
+ "episode",
61
+ "archived_episode",
62
+ "agent_file",
63
+ "rules",
64
+ "agents_md",
65
+ "proposal",
66
+ )
67
+ _MARKER_KINDS = (*_NOTE_KINDS, "rules", "agents_md", "log")
68
+
69
+ _CONFLICT_MARKER = re.compile(r"^(?:<{7}|>{7})(?: |$)|^={7}$")
70
+ _INJECTION = re.compile(
71
+ r"(?i)\b(?:ignore|disregard|forget)\b.{0,40}\b(?:previous|prior|above|earlier|all)\b.{0,20}"
72
+ r"\b(?:instructions?|prompts?|rules?)\b"
73
+ r"|\byou must now\b|\bfrom now on,? you\b|\bnew (?:system )?instructions?\s*:|\bsystem prompt\b"
74
+ )
75
+
76
+
77
+ @dataclass
78
+ class FileContext:
79
+ manifest: Manifest
80
+ tree: TopicTree
81
+
82
+ @property
83
+ def topics(self) -> dict[str, str]: # slug -> folder
84
+ return self.tree.paths
85
+
86
+ @classmethod
87
+ def for_engine(cls, engine: Engine) -> FileContext:
88
+ return cls(engine.manifest, engine.topics)
89
+
90
+
91
+ def _fm_line(doc: ParsedDoc, key: str) -> int:
92
+ """The line of `key` in the frontmatter, or of its `bw-` field (`stage` -> `bw-stage`)."""
93
+ if not doc.frontmatter.present:
94
+ return 1
95
+ lines = doc.text.splitlines()
96
+ for name in (key, FIELDS.get(key)):
97
+ if name is None:
98
+ continue
99
+ pattern = re.compile(rf"^{re.escape(name)}\s*:")
100
+ for idx in range(1, doc.frontmatter.end_line - 1):
101
+ if pattern.match(lines[idx]):
102
+ return idx + 1
103
+ return 1
104
+
105
+
106
+ def _set(values: dict[str, object]) -> Callable[[str], str]:
107
+ return lambda text: set_fields(text, values, order_first=("id", "type", "title"))
108
+
109
+
110
+ def _line_edit(original: str, new_line: str, hint: int) -> Callable[[str], str]:
111
+ return lambda text: replace_line_matching(text, original, new_line, hint)
112
+
113
+
114
+ def _item_edit(key: str, index: int, value: str) -> Callable[[str], str]:
115
+ return lambda text: replace_item(text, key, index, [value])
116
+
117
+
118
+ def check_file(
119
+ ctx: FileContext, rel: str, kind: str, data: bytes, *, fixes: bool = False
120
+ ) -> list[Problem]:
121
+ try:
122
+ text = data.decode("utf-8")
123
+ except UnicodeDecodeError:
124
+ return [Problem("BW111", rel, 1, "file is not valid UTF-8")]
125
+ return check_text(ctx, rel, kind, text, fixes=fixes)
126
+
127
+
128
+ def check_text(
129
+ ctx: FileContext, rel: str, kind: str, text: str, *, fixes: bool = False
130
+ ) -> list[Problem]:
131
+ problems: list[Problem] = []
132
+ if kind in _NOTE_KINDS:
133
+ problems += _check_note(ctx, rel, kind, text, fixes)
134
+ elif kind in _EPISODE_KINDS:
135
+ problems += _check_episode(ctx, rel, kind, text, fixes)
136
+ elif kind == "rules":
137
+ problems += _check_rules(ctx, rel, text)
138
+ if kind in _MARKER_KINDS:
139
+ problems += _conflict_markers(rel, text)
140
+ if kind in _SECRET_KINDS:
141
+ for hit in find_secrets(text):
142
+ problems.append(
143
+ Problem(
144
+ "BW701",
145
+ rel,
146
+ hit.line,
147
+ f"looks like a {hit.kind}; remove it and rotate the secret (git history may still hold it)",
148
+ )
149
+ )
150
+ if not fixes:
151
+ for p in problems:
152
+ p.fix = None
153
+ return problems
154
+
155
+
156
+ def _conflict_markers(rel: str, text: str) -> list[Problem]:
157
+ for idx, line in enumerate(mask_code(text.splitlines()), start=1):
158
+ if _CONFLICT_MARKER.match(line):
159
+ return [
160
+ Problem("BW114", rel, idx, "leftover merge conflict markers; resolve them by hand")
161
+ ]
162
+ return []
163
+
164
+
165
+ def _parse_problems(doc: ParsedDoc, rel: str) -> list[Problem]:
166
+ out = []
167
+ for line, code, message in doc.problems:
168
+ out.append(Problem(code, rel, line, message))
169
+ for key in doc.frontmatter.nested_keys:
170
+ out.append(
171
+ Problem(
172
+ "BW102", rel, _fm_line(doc, key), f"frontmatter key {key!r} holds a nested mapping"
173
+ )
174
+ )
175
+ return out
176
+
177
+
178
+ def _check_note(ctx: FileContext, rel: str, kind: str, text: str, fixes: bool) -> list[Problem]:
179
+ doc = parse_document(rel, text)
180
+ problems = _parse_problems(doc, rel)
181
+ fm = doc.fm
182
+ if not doc.frontmatter.error:
183
+ raw_id = fm.get("id")
184
+ if raw_id is None:
185
+ problems.append(
186
+ Problem(
187
+ "BW103",
188
+ rel,
189
+ 1,
190
+ "note has no id",
191
+ fix=Fix("mint an id", _set({"id": new_ulid()})),
192
+ )
193
+ )
194
+ elif not is_ulid(str(raw_id)):
195
+ problems.append(
196
+ Problem(
197
+ "BW104",
198
+ rel,
199
+ _fm_line(doc, "id"),
200
+ f"id {raw_id!r} is not a ULID",
201
+ fix=Fix("re-mint the id", _set({"id": new_ulid()})),
202
+ )
203
+ )
204
+ status = fm.get("status")
205
+ if status is not None and str(status) not in STATUSES:
206
+ new_status, stage = map_status(str(status))
207
+ values: dict[str, object] = {"status": new_status}
208
+ if stage and fm.get("stage") is None:
209
+ values[FIELDS["stage"]] = stage
210
+ problems.append(
211
+ Problem(
212
+ "BW106",
213
+ rel,
214
+ _fm_line(doc, "status"),
215
+ f"status {status!r} is not active, superseded, or archived; it belongs in stage",
216
+ fix=Fix(f"status: {new_status}, stage: {stage}", _set(values)),
217
+ )
218
+ )
219
+ for key, value in doc.frontmatter.raw.items():
220
+ if key.lower() not in RELATION_PROPERTIES or not property_links(value):
221
+ continue
222
+ pairs = relation_values_for(key, value)
223
+
224
+ def convert(text: str, key: str = key, pairs: list[tuple[str, str]] = pairs) -> str:
225
+ text = remove_field(text, key)
226
+ for bw_key, item in pairs:
227
+ text = add_items(text, bw_key, [item])
228
+ return text
229
+
230
+ verbs = ", ".join(dict.fromkeys(k for k, _ in pairs))
231
+ problems.append(
232
+ Problem(
233
+ "BW113",
234
+ rel,
235
+ _fm_line(doc, key),
236
+ f"property {key!r} holds links that read as relations; write them as {verbs}",
237
+ fix=Fix(f"convert {key} to {verbs}", convert),
238
+ )
239
+ )
240
+ if doc.section_count("claims") > 1:
241
+ problems.append(Problem("BW207", rel, None, "more than one ## Claims section"))
242
+ if doc.section_count("relations") > 1:
243
+ problems.append(Problem("BW311", rel, None, "more than one ## Relations section"))
244
+ lines = text.splitlines()
245
+ seen_ids: Counter[str] = Counter()
246
+ for claim in doc.claims:
247
+ last = lines[claim.last_line - 1]
248
+ if not claim.has_meta:
249
+ new_id = claim_id()
250
+ edit = (
251
+ _item_edit(claim.key, claim.index, claim_value(claim.text, {"id": new_id}))
252
+ if claim.key
253
+ else _line_edit(last, f"{last} {format_meta({'id': new_id})}", claim.last_line)
254
+ )
255
+ problems.append(
256
+ Problem(
257
+ "BW205",
258
+ rel,
259
+ claim.line,
260
+ "claim has no metadata",
261
+ fix=Fix("assign a claim id", edit),
262
+ )
263
+ )
264
+ continue
265
+ if claim.id:
266
+ seen_ids[claim.id] += 1
267
+ if seen_ids[claim.id] > 1:
268
+ new_id = claim_id()
269
+ if claim.key:
270
+ value = claim_value(claim.text, {**claim.meta, "id": new_id})
271
+ edit = _item_edit(claim.key, claim.index, value)
272
+ else:
273
+ new_line = last.replace(f"id:{claim.id}", f"id:{new_id}", 1)
274
+ edit = _line_edit(last, new_line, claim.last_line)
275
+ problems.append(
276
+ Problem(
277
+ "BW202",
278
+ rel,
279
+ claim.line,
280
+ f"duplicate claim id {claim.id}",
281
+ fix=Fix("re-mint the claim id", edit),
282
+ )
283
+ )
284
+ raw_inputs = claim.meta.get("inputs")
285
+ if raw_inputs is not None and raw_inputs not in INPUTS:
286
+ problems.append(
287
+ Problem(
288
+ "BW201",
289
+ rel,
290
+ claim.line,
291
+ f"claim inputs {raw_inputs!r} must be local, external, or unknown; "
292
+ "it reads as unknown",
293
+ )
294
+ )
295
+ if claim.inputs != "local" and _INJECTION.search(claim.text):
296
+ problems.append(
297
+ Problem(
298
+ "BW702",
299
+ rel,
300
+ claim.line,
301
+ "instruction-like text in an unverified claim; treat it as data",
302
+ )
303
+ )
304
+ for relation in doc.relations:
305
+ if relation.key in ALIAS_KEYS:
306
+ value = str(as_items(fm.get(relation.key))[relation.index])
307
+ moved = canonical_item(relation.written_verb, value)
308
+ problems.append(
309
+ Problem(
310
+ "BW303",
311
+ rel,
312
+ relation.line,
313
+ f"alias field {relation.key!r}; canonical is 'bw-{relation.verb}'",
314
+ fix=Fix(
315
+ f"move to bw-{relation.verb}",
316
+ lambda t, k=relation.key, v=value, m=moved: move_item(t, k, v, *m),
317
+ )
318
+ if moved
319
+ else None,
320
+ )
321
+ )
322
+ elif relation.is_alias:
323
+ original = lines[relation.line - 1]
324
+ rewritten = canonicalize_line(original)
325
+ problems.append(
326
+ Problem(
327
+ "BW303",
328
+ rel,
329
+ relation.line,
330
+ f"alias verb {relation.written_verb!r}; canonical is {relation.verb!r}",
331
+ fix=Fix(
332
+ f"rewrite as {relation.verb}",
333
+ _line_edit(original, rewritten, relation.line),
334
+ )
335
+ if rewritten
336
+ else None,
337
+ )
338
+ )
339
+ if is_format1(doc):
340
+ problems.append(
341
+ Problem(
342
+ "BW116",
343
+ rel,
344
+ None,
345
+ "format 1: ## Claims, ## Relations, or unprefixed stage; "
346
+ "move them to bw- frontmatter fields",
347
+ fix=Fix("convert to format 2", to_format2),
348
+ )
349
+ )
350
+ return problems
351
+
352
+
353
+ def _check_episode(ctx: FileContext, rel: str, kind: str, text: str, fixes: bool) -> list[Problem]:
354
+ doc = parse_document(rel, text)
355
+ problems = _parse_problems(doc, rel)
356
+ layout = ctx.manifest.layout
357
+ if doc.frontmatter.error or rel.startswith(layout.archive_dir + "/agents/"):
358
+ return problems # archived host memory files keep their own format
359
+ fm = doc.fm
360
+ base = layout.episodes_dir if kind == "episode" else layout.archive_episodes_dir
361
+ raw_id = fm.get("id")
362
+ values: dict[str, object] = {}
363
+ missing = []
364
+ if raw_id is None:
365
+ missing.append("id")
366
+ values["id"] = new_ulid()
367
+ if str(fm.get("origin")) not in ORIGINS:
368
+ missing.append("origin")
369
+ values["origin"] = "agent"
370
+ if str(fm.get("inputs")) not in INPUTS:
371
+ missing.append("inputs")
372
+ values["inputs"] = "unknown"
373
+ if missing:
374
+ move_to = episode_path(base, str(values["id"])) if "id" in values else None
375
+ legacy = doc.frontmatter.legacy_fields
376
+ written = {k if k == "id" or k in legacy else FIELDS[k]: v for k, v in values.items()}
377
+ problems.append(
378
+ Problem(
379
+ "BW503",
380
+ rel,
381
+ 1,
382
+ f"episode lacks or has invalid provenance fields: {', '.join(missing)}",
383
+ fix=Fix("fill provenance fields", _set(written), move_to=move_to),
384
+ )
385
+ )
386
+ if raw_id is not None:
387
+ if not is_ulid(str(raw_id)):
388
+ problems.append(
389
+ Problem("BW502", rel, _fm_line(doc, "id"), f"episode id {raw_id!r} is not a ULID")
390
+ )
391
+ else:
392
+ expected = episode_path(base, str(raw_id))
393
+ if rel != expected:
394
+ problems.append(
395
+ Problem(
396
+ "BW502",
397
+ rel,
398
+ None,
399
+ f"episode path should be {expected}",
400
+ fix=Fix(f"move to {expected}", move_to=expected),
401
+ )
402
+ )
403
+ return problems
404
+
405
+
406
+ def _check_rules(ctx: FileContext, rel: str, text: str) -> list[Problem]:
407
+ budgets = ctx.manifest.budgets
408
+ problems: list[Problem] = []
409
+ line_count = len(text.splitlines())
410
+ if line_count > budgets.rules_max_lines or len(text) > budgets.rules_max_chars:
411
+ problems.append(
412
+ Problem(
413
+ "BW402",
414
+ rel,
415
+ None,
416
+ f"rules.md has {line_count} lines and {len(text)} characters "
417
+ f"(budget {budgets.rules_max_lines} lines, {budgets.rules_max_chars} characters)",
418
+ )
419
+ )
420
+ rules, malformed = parse_rules(text)
421
+ for line, message in malformed:
422
+ problems.append(Problem("BW603", rel, line, message))
423
+ bad_lines = {line for line, _ in malformed}
424
+ for rule in rules:
425
+ if rule.line in bad_lines:
426
+ continue
427
+ if not rule.meta.get("quote"):
428
+ problems.append(
429
+ Problem("BW601", rel, rule.line, "rule has no quote (the user's own words)")
430
+ )
431
+ if rule.meta.get("origin") != "owner":
432
+ problems.append(
433
+ Problem(
434
+ "BW602",
435
+ rel,
436
+ rule.line,
437
+ f"rule origin is {rule.meta.get('origin')!r}, not 'owner'",
438
+ )
439
+ )
440
+ return problems
441
+
442
+
443
+ _GATED_KINDS = (*_NOTE_KINDS, *_EPISODE_KINDS, "rules")
444
+
445
+
446
+ def run_gate(engine: Engine) -> Callable[[Run], list[Problem]]:
447
+ """The V11 check for runs: strict errors a change introduces in the files it touches.
448
+
449
+ Errors already present before the change do not block it, so one broken note written by
450
+ hand cannot stall every later run that touches it.
451
+ """
452
+ from brainwiki.index import classify
453
+
454
+ ctx = FileContext.for_engine(engine)
455
+
456
+ def check(run: Run) -> list[Problem]:
457
+ introduced: list[Problem] = []
458
+ for path, before, after in run.text_changes():
459
+ if after is None:
460
+ continue
461
+ kind = classify(engine, path, ctx.topics)
462
+ if kind not in _GATED_KINDS:
463
+ continue
464
+
465
+ def errors(text: str | None, path: str = path, kind: str = kind) -> list[Problem]:
466
+ if text is None:
467
+ return []
468
+ return [
469
+ p
470
+ for p in check_text(ctx, path, kind, text)
471
+ if severity(p.code, "strict") == "error"
472
+ ]
473
+
474
+ old = Counter((p.code, p.message) for p in errors(before))
475
+ for problem in errors(after):
476
+ key = (problem.code, problem.message)
477
+ if old[key]:
478
+ old[key] -= 1
479
+ else:
480
+ introduced.append(problem)
481
+ return introduced
482
+
483
+ return check
@@ -0,0 +1,87 @@
1
+ """`brain lint --fix`: all fixes of one invocation form one run (design doc 06 §5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from brainwiki.errors import Conflict
10
+ from brainwiki.lint._files import run_gate
11
+ from brainwiki.lint._model import LintResult, Problem
12
+ from brainwiki.lint._runner import lint
13
+ from brainwiki.runs import Run, RunRecord
14
+
15
+ if TYPE_CHECKING:
16
+ from brainwiki.engine import Engine
17
+
18
+
19
+ @dataclass
20
+ class FixOutcome:
21
+ applied: list[Problem]
22
+ record: RunRecord | None
23
+ diff: str
24
+ remaining: LintResult
25
+
26
+
27
+ def plan(engine: Engine, result: LintResult, *, include_info: bool) -> tuple[Run, list[Problem]]:
28
+ """Stage every applicable fix in one run. Info-level fixes only when explicitly selected."""
29
+ run = Run(engine, "lint-fix")
30
+ groups: dict[str, list[Problem]] = defaultdict(list)
31
+ for problem in result.problems:
32
+ if problem.fix is None or (problem.severity == "info" and not include_info):
33
+ continue
34
+ groups[problem.fix.target or problem.path].append(problem)
35
+ applied: list[Problem] = []
36
+ for target, problems in groups.items():
37
+ original = run.read(target)
38
+ text = original
39
+ for problem in problems:
40
+ fix = problem.fix
41
+ assert fix is not None
42
+ if fix.create is not None:
43
+ if text is None:
44
+ text = fix.create
45
+ applied.append(problem)
46
+ continue
47
+ if fix.edit is not None and text is not None:
48
+ updated = fix.edit(text)
49
+ if updated != text:
50
+ text = updated
51
+ applied.append(problem)
52
+ if text is not None and text != original:
53
+ run.write(target, text)
54
+ for problem in problems:
55
+ fix = problem.fix
56
+ assert fix is not None
57
+ if fix.move_to:
58
+ try:
59
+ run.move(target, fix.move_to)
60
+ except Conflict:
61
+ continue
62
+ if problem not in applied:
63
+ applied.append(problem)
64
+ break
65
+ run.summary = f"lint --fix: {len(applied)} fix{'es' if len(applied) != 1 else ''}"
66
+ return run, applied
67
+
68
+
69
+ def fix(
70
+ engine: Engine,
71
+ *,
72
+ paths: list[str] | None = None,
73
+ profile: str | None = None,
74
+ select: list[str] | None = None,
75
+ ignore: list[str] | None = None,
76
+ dry_run: bool = False,
77
+ ) -> FixOutcome:
78
+ result = lint(engine, paths=paths, profile=profile, select=select, ignore=ignore, fixes=True)
79
+ run, applied = plan(engine, result, include_info=bool(select))
80
+ if not run.entries:
81
+ return FixOutcome([], None, "", result)
82
+ if dry_run:
83
+ return FixOutcome(applied, None, run.diff(), result)
84
+ lines = [f"- {p.fix.target or p.path}: {p.code} {p.fix.description}" for p in applied if p.fix]
85
+ record = run.commit(check=run_gate(engine), log_lines=lines[:40])
86
+ remaining = lint(engine, paths=paths, profile=profile, select=select, ignore=ignore)
87
+ return FixOutcome(applied, record, "", remaining)
@@ -0,0 +1,54 @@
1
+ """Git pre-commit integration: lint exactly what will be committed (design doc 06 §8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from brainwiki.errors import Conflict, NotAvailable
11
+ from brainwiki.runs import git_dir_path, is_work_tree, run_git
12
+
13
+ _HOOK = """#!/bin/sh
14
+ # brain-wiki: lint the staged files of this brain (installed by `brain lint --install-hook`).
15
+ command -v brain >/dev/null 2>&1 || {{ echo "brain-wiki: 'brain' is not on PATH; skipping lint" >&2; exit 0; }}
16
+ cd "$(git rev-parse --show-toplevel)/{rel}" || exit 1
17
+ exec brain lint --staged --brain .
18
+ """
19
+
20
+
21
+ def staged_files(root: Path) -> dict[str, bytes]:
22
+ """Brain-relative path -> staged bytes, for files added, copied, modified, or renamed."""
23
+ names = run_git(
24
+ root, "diff", "--cached", "--name-only", "--relative", "--diff-filter=ACMR", "-z"
25
+ )
26
+ if names.returncode != 0:
27
+ raise NotAvailable(names.stderr.strip() or "git diff failed")
28
+ out: dict[str, bytes] = {}
29
+ for name in filter(None, names.stdout.split("\0")):
30
+ shown = subprocess.run(
31
+ ["git", "-C", str(root), "show", f":./{name}"], capture_output=True, check=False
32
+ )
33
+ if shown.returncode == 0:
34
+ out[name] = shown.stdout
35
+ return out
36
+
37
+
38
+ def install_hook(root: Path) -> Path:
39
+ if not is_work_tree(root):
40
+ raise NotAvailable(f"{root} is not inside a git work tree")
41
+ hooks = git_dir_path(root, "hooks")
42
+ if hooks is None:
43
+ raise NotAvailable("could not locate the git hooks directory")
44
+ target = hooks / "pre-commit"
45
+ if target.exists():
46
+ raise Conflict(
47
+ f"{target} already exists; add this line to it instead: brain lint --staged --brain {root}"
48
+ )
49
+ top = run_git(root, "rev-parse", "--show-toplevel").stdout.strip()
50
+ rel = os.path.relpath(root, top).replace(os.sep, "/") if top else "."
51
+ hooks.mkdir(parents=True, exist_ok=True)
52
+ target.write_text(_HOOK.format(rel="" if rel == "." else rel), encoding="utf-8")
53
+ target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
54
+ return target