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.
- brain_wiki-0.1.0.dev0.dist-info/METADATA +231 -0
- brain_wiki-0.1.0.dev0.dist-info/RECORD +141 -0
- brain_wiki-0.1.0.dev0.dist-info/WHEEL +4 -0
- brain_wiki-0.1.0.dev0.dist-info/entry_points.txt +4 -0
- brain_wiki-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- brainwiki/__init__.py +17 -0
- brainwiki/__main__.py +5 -0
- brainwiki/assets/plugin/.claude-plugin/marketplace.json +16 -0
- brainwiki/assets/plugin/.claude-plugin/plugin.json +19 -0
- brainwiki/assets/plugin/.mcp.json +16 -0
- brainwiki/assets/plugin/mcp.json +17 -0
- brainwiki/assets/plugin/mcp_config.json +15 -0
- brainwiki/assets/plugin/plugin.json +37 -0
- brainwiki/assets/plugin/rules/brain-wiki.md +30 -0
- brainwiki/assets/plugin/server.json +30 -0
- brainwiki/assets/plugin/skills/brain/SKILL.md +145 -0
- brainwiki/assets/plugin/skills/brain/references/format.md +149 -0
- brainwiki/assets/plugin/skills/brain-maintain/SKILL.md +111 -0
- brainwiki/assets/plugin/skills/brain-maintain/references/consolidate.md +91 -0
- brainwiki/assets/plugin/skills/brain-maintain/references/harvest.md +144 -0
- brainwiki/assets/plugin/skills/brain-setup/SKILL.md +184 -0
- brainwiki/assets/plugin/skills/brain-setup/references/hosts.md +88 -0
- brainwiki/assets/plugin/skills/brain-setup/references/privacy.md +67 -0
- brainwiki/assets/prompts/consolidate.md +89 -0
- brainwiki/assets/prompts/distill.md +96 -0
- brainwiki/assets/prompts/harvest.md +142 -0
- brainwiki/bench/__init__.py +8 -0
- brainwiki/bench/_generate.py +440 -0
- brainwiki/bench/_report.py +166 -0
- brainwiki/bench/_suites.py +959 -0
- brainwiki/cli/__init__.py +7 -0
- brainwiki/cli/_cmd_bench.py +101 -0
- brainwiki/cli/_cmd_browse.py +281 -0
- brainwiki/cli/_cmd_consolidate.py +108 -0
- brainwiki/cli/_cmd_core.py +155 -0
- brainwiki/cli/_cmd_harvest.py +137 -0
- brainwiki/cli/_cmd_hosts.py +52 -0
- brainwiki/cli/_cmd_import.py +149 -0
- brainwiki/cli/_cmd_lint.py +117 -0
- brainwiki/cli/_cmd_read.py +131 -0
- brainwiki/cli/_cmd_runs.py +66 -0
- brainwiki/cli/_cmd_viz.py +57 -0
- brainwiki/cli/_cmd_write.py +122 -0
- brainwiki/cli/_main.py +511 -0
- brainwiki/cli/_output.py +19 -0
- brainwiki/config/__init__.py +56 -0
- brainwiki/config/_dirs.py +69 -0
- brainwiki/config/_manifest.py +307 -0
- brainwiki/config/_resolve.py +169 -0
- brainwiki/config/_user.py +183 -0
- brainwiki/consolidate/__init__.py +19 -0
- brainwiki/consolidate/_apply.py +408 -0
- brainwiki/consolidate/_packet.py +489 -0
- brainwiki/consolidate/_pipeline.py +215 -0
- brainwiki/consolidate/_presets.py +230 -0
- brainwiki/consolidate/_review.py +104 -0
- brainwiki/consolidate/_schema.py +153 -0
- brainwiki/consolidate/_validate.py +252 -0
- brainwiki/doctor.py +216 -0
- brainwiki/engine.py +80 -0
- brainwiki/errors.py +81 -0
- brainwiki/format/__init__.py +112 -0
- brainwiki/format/_agents_md.py +26 -0
- brainwiki/format/_claims.py +95 -0
- brainwiki/format/_document.py +203 -0
- brainwiki/format/_edit.py +94 -0
- brainwiki/format/_episode.py +67 -0
- brainwiki/format/_fields.py +350 -0
- brainwiki/format/_frontmatter.py +175 -0
- brainwiki/format/_links.py +72 -0
- brainwiki/format/_meta.py +67 -0
- brainwiki/format/_normalize.py +70 -0
- brainwiki/format/_relations.py +211 -0
- brainwiki/format/_rules.py +41 -0
- brainwiki/harvest.py +414 -0
- brainwiki/hosts/__init__.py +22 -0
- brainwiki/hosts/_antigravity.py +163 -0
- brainwiki/hosts/_claude_code.py +174 -0
- brainwiki/hosts/_codex.py +149 -0
- brainwiki/hosts/_connect.py +443 -0
- brainwiki/hosts/_detect.py +39 -0
- brainwiki/hosts/_instructions.py +220 -0
- brainwiki/hosts/_sessions.py +176 -0
- brainwiki/ids.py +69 -0
- brainwiki/importer/__init__.py +17 -0
- brainwiki/importer/_episodes.py +112 -0
- brainwiki/importer/_into.py +102 -0
- brainwiki/importer/_normalize.py +64 -0
- brainwiki/importer/_overlay.py +199 -0
- brainwiki/importer/_survey.py +167 -0
- brainwiki/index/__init__.py +44 -0
- brainwiki/index/_brains.py +119 -0
- brainwiki/index/_db.py +131 -0
- brainwiki/index/_guards.py +154 -0
- brainwiki/index/_neighbors.py +174 -0
- brainwiki/index/_orient.py +400 -0
- brainwiki/index/_read.py +285 -0
- brainwiki/index/_resolver.py +147 -0
- brainwiki/index/_scanner.py +665 -0
- brainwiki/index/_schema.py +164 -0
- brainwiki/index/_search.py +659 -0
- brainwiki/index/_topics.py +253 -0
- brainwiki/index/_written.py +29 -0
- brainwiki/lifecycle.py +158 -0
- brainwiki/lint/__init__.py +20 -0
- brainwiki/lint/_catalog.py +85 -0
- brainwiki/lint/_cross.py +649 -0
- brainwiki/lint/_files.py +483 -0
- brainwiki/lint/_fixes.py +87 -0
- brainwiki/lint/_hook.py +54 -0
- brainwiki/lint/_model.py +103 -0
- brainwiki/lint/_reports.py +242 -0
- brainwiki/lint/_runner.py +156 -0
- brainwiki/logs.py +34 -0
- brainwiki/mcp/__init__.py +7 -0
- brainwiki/mcp/_server.py +917 -0
- brainwiki/origin.py +97 -0
- brainwiki/runs/__init__.py +21 -0
- brainwiki/runs/_git.py +89 -0
- brainwiki/runs/_lock.py +42 -0
- brainwiki/runs/_record.py +145 -0
- brainwiki/runs/_transaction.py +441 -0
- brainwiki/runs/_undo.py +159 -0
- brainwiki/setup_wizard.py +143 -0
- brainwiki/store/__init__.py +7 -0
- brainwiki/store/_local.py +153 -0
- brainwiki/viz/__init__.py +9 -0
- brainwiki/viz/_export.py +414 -0
- brainwiki/viz/_lod.py +455 -0
- brainwiki/viz/_server.py +327 -0
- brainwiki/viz/app/BUILD_INFO.json +5 -0
- brainwiki/viz/app/THIRD_PARTY_NOTICES.txt +1519 -0
- brainwiki/viz/app/assets/index-gK1pnQdO.js +4496 -0
- brainwiki/viz/app/assets/style-Cg8NUCst.css +1 -0
- brainwiki/viz/app/index.html +14 -0
- brainwiki/write/__init__.py +23 -0
- brainwiki/write/_edits.py +730 -0
- brainwiki/write/_episode.py +195 -0
- brainwiki/write/_move.py +181 -0
- brainwiki/write/_rules.py +204 -0
- brainwiki/write/_secrets.py +108 -0
brainwiki/lint/_files.py
ADDED
|
@@ -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
|
brainwiki/lint/_fixes.py
ADDED
|
@@ -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)
|
brainwiki/lint/_hook.py
ADDED
|
@@ -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
|