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,730 @@
1
+ """Checked edits: validate direct edits of notes, then record them as runs (design doc 05 §2.3).
2
+
3
+ An agent (or the user) edits a note's file, then runs `brain check` on it. The edit is compared
4
+ with the note's last recorded version, kept in the index, and either recorded as a run of kind
5
+ `edit` with that version as its pre-image, routed to review, or refused with every problem.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import difflib
11
+ import json
12
+ import re
13
+ import zlib
14
+ from collections import Counter
15
+ from dataclasses import dataclass, field
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from brainwiki.errors import Conflict, Forbidden, FormatError, NotFound, UsageError
21
+ from brainwiki.format import (
22
+ Claim,
23
+ clean_claim_text,
24
+ format_meta,
25
+ format_value,
26
+ parse_document,
27
+ split,
28
+ strip_meta_comments,
29
+ )
30
+ from brainwiki.ids import claim_id, is_prefixed
31
+ from brainwiki.ids import run_id as new_run_id
32
+ from brainwiki.store import sha256_bytes
33
+
34
+ if TYPE_CHECKING:
35
+ from brainwiki.engine import Engine
36
+
37
+ _PROTECTED_FILES = ("rules.md", "log.md", "brain.toml", "AGENTS.md")
38
+ _LOSS_WARN = 0.10
39
+ _LOSS_ERROR = 0.50
40
+ _DIFF_LINES = 60
41
+ _SYNC_COPY = re.compile(r"(?i)\.sync-conflict-|\(conflicted copy|\bconflict(ed)?[- ]copy\b")
42
+ _JSON_BLOCK = re.compile(r"```json\n(?P<body>.*?)\n```", re.S)
43
+
44
+
45
+ @dataclass
46
+ class _Problem:
47
+ path: str
48
+ code: str
49
+ message: str
50
+ severity: str = "error"
51
+ line: int | None = None
52
+
53
+ def to_json(self) -> dict[str, Any]:
54
+ return {
55
+ "path": self.path,
56
+ "line": self.line,
57
+ "code": self.code,
58
+ "severity": self.severity,
59
+ "message": self.message,
60
+ }
61
+
62
+ def __str__(self) -> str:
63
+ where = f"{self.path}:{self.line}" if self.line else self.path
64
+ return f"{where} {self.severity} {self.code}: {self.message}"
65
+
66
+
67
+ @dataclass
68
+ class _Change:
69
+ path: str
70
+ base: bytes | None # last recorded version; None when recorded as absent
71
+ current: bytes | None # the file now; None when deleted
72
+ final: bytes | None = None # what to record: current plus stamps
73
+ added_claims: int = 0
74
+ problems: list[_Problem] = field(default_factory=list)
75
+
76
+ @property
77
+ def kind(self) -> str:
78
+ if self.base is None:
79
+ return "created"
80
+ return "deleted" if self.current is None else "changed"
81
+
82
+
83
+ # -- baselines -------------------------------------------------------------------------
84
+
85
+
86
+ def _baseline(engine: Engine, rel: str) -> tuple[bool, bytes | None]:
87
+ """(whether a recorded version exists, its content; None when recorded as absent)."""
88
+ row = engine.db.execute("SELECT sha256, content FROM baselines WHERE path=?", (rel,)).fetchone()
89
+ if row is None:
90
+ return False, None
91
+ return True, zlib.decompress(row["content"]) if row["content"] is not None else None
92
+
93
+
94
+ def _read(engine: Engine, rel: str) -> bytes | None:
95
+ path = engine.store.abspath(rel)
96
+ return path.read_bytes() if path.is_file() else None
97
+
98
+
99
+ def _normalize_path(engine: Engine, raw: str) -> str:
100
+ """A brain-relative path from a relative or absolute one; never outside the brain."""
101
+ text = raw.strip()
102
+ if not text:
103
+ raise UsageError("empty path")
104
+ candidate = Path(text).expanduser()
105
+ root = engine.brain.root
106
+ if candidate.is_absolute():
107
+ try:
108
+ rel = candidate.resolve().relative_to(root)
109
+ except ValueError:
110
+ raise UsageError(f"{raw} is outside the brain at {root}") from None
111
+ text = rel.as_posix()
112
+ text = text.removeprefix("./")
113
+ if text.startswith("../") or "/../" in text or text == "..":
114
+ raise UsageError(f"{raw} is outside the brain")
115
+ return text
116
+
117
+
118
+ def _protected_reason(engine: Engine, rel: str) -> str | None:
119
+ layout = engine.manifest.layout
120
+ if rel in _PROTECTED_FILES:
121
+ return f"{rel} is never edited directly"
122
+ for folder, what in (
123
+ (layout.inbox_dir, "inbox files (episodes and proposals)"),
124
+ (layout.archive_dir, "the archive"),
125
+ (layout.reports_dir, "reports, which brain-wiki regenerates"),
126
+ ):
127
+ if rel.startswith(folder + "/"):
128
+ return f"{what} are never edited directly"
129
+ return None
130
+
131
+
132
+ def _pending_edits(engine: Engine) -> list[dict[str, Any]]:
133
+ """Notes whose file differs from their last recorded version, with diffs."""
134
+ engine.scan()
135
+ rows = engine.db.execute(
136
+ """SELECT b.path, b.sha256 AS base, b.since, f.sha256 AS current
137
+ FROM baselines b LEFT JOIN files f ON f.path = b.path
138
+ WHERE f.sha256 IS NOT b.sha256 ORDER BY b.path"""
139
+ ).fetchall()
140
+ out = []
141
+ for row in rows:
142
+ rel = row["path"]
143
+ _, base = _baseline(engine, rel)
144
+ current = _read(engine, rel)
145
+ if current is None and base is None:
146
+ continue
147
+ change = _Change(rel, base, current)
148
+ out.append(
149
+ {"path": rel, "change": change.kind, "since": row["since"], "diff": _diff(change)}
150
+ )
151
+ return out
152
+
153
+
154
+ # -- checks ----------------------------------------------------------------------------
155
+
156
+
157
+ def _text(data: bytes | None) -> str:
158
+ return data.decode("utf-8", "replace") if data is not None else ""
159
+
160
+
161
+ def _diff(change: _Change, limit: int = _DIFF_LINES) -> str:
162
+ lines = list(
163
+ difflib.unified_diff(
164
+ _text(change.base).splitlines(keepends=True),
165
+ _text(change.final if change.final is not None else change.current).splitlines(
166
+ keepends=True
167
+ ),
168
+ fromfile=f"a/{change.path}" if change.base is not None else "/dev/null",
169
+ tofile=f"b/{change.path}" if change.current is not None else "/dev/null",
170
+ )
171
+ )
172
+ if len(lines) > limit:
173
+ lines = [*lines[:limit], f"… {len(lines) - limit} more diff lines\n"]
174
+ return "".join(lines)
175
+
176
+
177
+ def _words(data: bytes | None) -> list[str]:
178
+ if data is None:
179
+ return []
180
+ doc = parse_document("x.md", _text(data))
181
+ return strip_meta_comments(doc.frontmatter.body).split()
182
+
183
+
184
+ def _loss(change: _Change) -> float:
185
+ """The share of the recorded version's words that the edit removed."""
186
+ before = _words(change.base)
187
+ if not before:
188
+ return 0.0
189
+ after = _words(change.current)
190
+ matcher = difflib.SequenceMatcher(None, before, after, autojunk=False)
191
+ removed = sum(
192
+ i2 - i1 for tag, i1, i2, _, _ in matcher.get_opcodes() if tag in ("delete", "replace")
193
+ )
194
+ return removed / len(before)
195
+
196
+
197
+ def _claims(data: bytes | None) -> list[Claim]:
198
+ return parse_document("x.md", _text(data)).claims if data is not None else []
199
+
200
+
201
+ _SUPERSEDE = "move it to bw-claims-superseded with `by: <new claim id>`, then add the new claim"
202
+
203
+
204
+ _PROVENANCE = ("origin", "inputs", "src")
205
+
206
+
207
+ def _provenance(claim: Claim) -> tuple[str, str, str]:
208
+ meta = claim.meta
209
+ return (str(meta.get("origin", "")), str(meta.get("inputs", "local")), str(meta.get("src", "")))
210
+
211
+
212
+ def _check_claims(change: _Change, *, agent: bool) -> list[Claim]:
213
+ """Problems for deleted or reworded claims, and for agents changing a claim's provenance;
214
+ returns the claims the edit added."""
215
+ before, after = _claims(change.base), _claims(change.current)
216
+ after_ids = {c.id: c for c in after if c.id}
217
+ after_texts = {clean_claim_text(c.text) for c in after}
218
+ for claim in before:
219
+ text = clean_claim_text(claim.text)
220
+ if claim.id:
221
+ now = after_ids.get(claim.id)
222
+ if now is None:
223
+ change.problems.append(
224
+ _Problem(
225
+ change.path,
226
+ "claim_deleted",
227
+ f'claim "{_short(text)}" was deleted; supersede it instead ({_SUPERSEDE})',
228
+ line=claim.line,
229
+ )
230
+ )
231
+ elif (
232
+ claim.status == "active"
233
+ and now.status == "active"
234
+ and clean_claim_text(now.text) != text
235
+ ):
236
+ change.problems.append(
237
+ _Problem(
238
+ change.path,
239
+ "claim_reworded",
240
+ f'claim "{_short(text)}" was reworded in place; supersede it instead '
241
+ f"({_SUPERSEDE}), so its history is kept",
242
+ line=now.line,
243
+ )
244
+ )
245
+ elif agent and _provenance(now) != _provenance(claim):
246
+ change.problems.append(
247
+ _Problem(
248
+ change.path,
249
+ "claim_provenance_changed",
250
+ f'the provenance of claim "{_short(text)}" ({", ".join(_PROVENANCE)}) '
251
+ "changed; only brain-wiki and the user set it, so put it back",
252
+ line=now.line,
253
+ )
254
+ )
255
+ elif claim.status == "active" and text not in after_texts:
256
+ change.problems.append(
257
+ _Problem(
258
+ change.path,
259
+ "claim_deleted",
260
+ f'claim "{_short(text)}" was deleted or reworded; supersede it instead',
261
+ line=claim.line,
262
+ )
263
+ )
264
+ before_ids = {c.id for c in before if c.id}
265
+ before_texts = {clean_claim_text(c.text) for c in before}
266
+ return [
267
+ c
268
+ for c in after
269
+ if (c.id and c.id not in before_ids)
270
+ or (not c.id and clean_claim_text(c.text) not in before_texts)
271
+ ]
272
+
273
+
274
+ def _short(text: str, width: int = 60) -> str:
275
+ return text if len(text) <= width else text[: width - 1] + "…"
276
+
277
+
278
+ def _stamp_claims(data: bytes, claims: list[Claim]) -> bytes:
279
+ """Mark claims an agent added as its own and unverified, whatever it wrote (05 §3)."""
280
+ from brainwiki.format import claim_value, replace_item
281
+
282
+ text = data.decode("utf-8")
283
+ today = datetime.now(UTC).strftime("%Y-%m-%d")
284
+ for claim in claims:
285
+ meta = dict(claim.meta)
286
+ meta.setdefault("id", claim_id())
287
+ meta["origin"] = "agent"
288
+ meta["inputs"] = "unknown"
289
+ meta.setdefault("at", today)
290
+ if claim.key is not None:
291
+ text = replace_item(text, claim.key, claim.index, [claim_value(claim.text, meta)])
292
+ continue
293
+ lines = text.splitlines(keepends=True) # a format 1 note: the claim is a body line
294
+ idx = claim.last_line - 1
295
+ if not 0 <= idx < len(lines):
296
+ continue
297
+ line = lines[idx]
298
+ ending = "\n" if line.endswith("\n") else ""
299
+ body = re.sub(r"\s*<!--.*?-->\s*$", "", line.rstrip("\n"))
300
+ lines[idx] = f"{body} {format_meta(meta)}{ending}"
301
+ text = "".join(lines)
302
+ return text.encode("utf-8")
303
+
304
+
305
+ def _lint_new(engine: Engine, change: _Change) -> None:
306
+ """File-rule problems the edit introduced (problems the note already had are not its fault)."""
307
+ from brainwiki.lint import lint_text
308
+
309
+ if change.current is None:
310
+ return
311
+ before = (
312
+ Counter((p.code, p.message) for p in lint_text(engine, change.path, _text(change.base)))
313
+ if change.base is not None
314
+ else Counter()
315
+ )
316
+ for problem in lint_text(engine, change.path, _text(change.current)):
317
+ key = (problem.code, problem.message)
318
+ if before[key]:
319
+ before[key] -= 1
320
+ continue
321
+ change.problems.append(
322
+ _Problem(
323
+ change.path,
324
+ problem.code,
325
+ problem.message,
326
+ "error" if problem.severity == "error" else "warning",
327
+ problem.line,
328
+ )
329
+ )
330
+
331
+
332
+ def _check_ids(engine: Engine, change: _Change) -> None:
333
+ if change.current is None:
334
+ return
335
+ after_id = parse_document(change.path, _text(change.current)).fm.get("id")
336
+ if change.base is not None:
337
+ before_id = parse_document(change.path, _text(change.base)).fm.get("id")
338
+ if before_id and str(before_id) != str(after_id or ""):
339
+ change.problems.append(
340
+ _Problem(
341
+ change.path,
342
+ "id_changed",
343
+ f"the note's id changed from {before_id}; put it back",
344
+ )
345
+ )
346
+ if after_id:
347
+ clash = engine.db.execute(
348
+ "SELECT path FROM nodes WHERE fm_id=? AND path != ?", (str(after_id), change.path)
349
+ ).fetchone()
350
+ if clash:
351
+ change.problems.append(
352
+ _Problem(
353
+ change.path, "id_duplicate", f"id {after_id} is already used by {clash['path']}"
354
+ )
355
+ )
356
+
357
+
358
+ def _check_links(engine: Engine, change: _Change) -> None:
359
+ if change.current is None:
360
+ return
361
+ before = _text(change.base)
362
+ for row in engine.db.execute(
363
+ """SELECT DISTINCT e.raw_target, e.line FROM edges e JOIN nodes n ON n.id = e.declared_in
364
+ WHERE n.path = ? AND e.other IS NULL""",
365
+ (change.path,),
366
+ ):
367
+ if row["raw_target"] not in before:
368
+ change.problems.append(
369
+ _Problem(
370
+ change.path,
371
+ "link_unresolved",
372
+ f"[[{row['raw_target']}]] does not resolve to a note",
373
+ "warning",
374
+ row["line"],
375
+ )
376
+ )
377
+
378
+
379
+ def _validate(engine: Engine, change: _Change, *, allow_shrink: bool) -> list[Claim]:
380
+ rel = change.path
381
+ if _SYNC_COPY.search(rel.rsplit("/", 1)[-1]):
382
+ change.problems.append(
383
+ _Problem(
384
+ rel, "sync_debris", "this looks like a sync-conflict copy; merge it and delete it"
385
+ )
386
+ )
387
+ added = _check_claims(change, agent=engine.profile.name != "owner")
388
+ loss = _loss(change)
389
+ if loss > _LOSS_ERROR and not allow_shrink:
390
+ change.problems.append(
391
+ _Problem(
392
+ rel,
393
+ "loss",
394
+ f"the edit removed {loss:.0%} of the note's text; restore what was lost, or pass "
395
+ "allow_shrink if the removal is intended",
396
+ )
397
+ )
398
+ elif loss > _LOSS_WARN:
399
+ change.problems.append(
400
+ _Problem(rel, "loss", f"the edit removed {loss:.0%} of the note's text", "warning")
401
+ )
402
+ _check_ids(engine, change)
403
+ _lint_new(engine, change)
404
+ _check_links(engine, change)
405
+ return added
406
+
407
+
408
+ def _policy(engine: Engine, rel: str) -> str:
409
+ from brainwiki.config import AGENT_EDITS
410
+
411
+ policy = engine.topics.setting(engine.topics.for_path(rel), "agent_edits") or "allow"
412
+ return policy if policy in AGENT_EDITS else "deny"
413
+
414
+
415
+ # -- the command -----------------------------------------------------------------------
416
+
417
+
418
+ def check_edits(
419
+ engine: Engine,
420
+ paths: list[str] | None = None,
421
+ *,
422
+ allow_shrink: bool = False,
423
+ adopt: bool = False,
424
+ ) -> dict[str, Any]:
425
+ """Validate and record direct edits of `paths`; without paths, list pending edits.
426
+
427
+ Agents need the `edit` capability and follow each topic's `agent_edits` policy. `adopt`
428
+ (owner only) records the edits as the user's even when checks report problems.
429
+ """
430
+ from brainwiki.index import NOTE_KINDS, classify, discovered_topics
431
+
432
+ if not paths:
433
+ pending = _pending_edits(engine)
434
+ return {"brain": engine.brain.display_name, "status": "pending", "pending": pending}
435
+ owner = engine.profile.name == "owner"
436
+ if adopt and not owner:
437
+ raise Forbidden("only the user can adopt edits (`brain check --adopt` from a terminal)")
438
+ if not owner and not engine.profile.can("edit"):
439
+ raise Forbidden(
440
+ engine.profile.refusal(engine.host, "edit", "edit notes")
441
+ + "; meanwhile, log an episode instead"
442
+ )
443
+ engine.scan(force=True)
444
+ topics = discovered_topics(engine)
445
+ changes: list[_Change] = []
446
+ problems: list[_Problem] = []
447
+ unchanged: list[str] = []
448
+ for raw in dict.fromkeys(paths):
449
+ rel = _normalize_path(engine, raw)
450
+ reason = _protected_reason(engine, rel)
451
+ if reason:
452
+ problems.append(_Problem(rel, "protected", reason))
453
+ continue
454
+ if classify(engine, rel, topics) not in NOTE_KINDS:
455
+ problems.append(
456
+ _Problem(rel, "not_a_note", "brain_check records edits of notes and hubs only")
457
+ )
458
+ continue
459
+ known, base = _baseline(engine, rel)
460
+ current = _read(engine, rel)
461
+ if not known and current is None:
462
+ raise NotFound(f"no note at {rel}")
463
+ if known and (sha256_bytes(base) if base is not None else None) == (
464
+ sha256_bytes(current) if current is not None else None
465
+ ):
466
+ unchanged.append(rel)
467
+ continue
468
+ changes.append(_Change(rel, base, current))
469
+ review: list[_Change] = []
470
+ for change in changes:
471
+ added = _validate(engine, change, allow_shrink=allow_shrink)
472
+ change.final = change.current
473
+ if change.current is not None and added and not owner:
474
+ change.final = _stamp_claims(change.current, added)
475
+ change.added_claims = len(added)
476
+ if not owner:
477
+ policy = _policy(engine, change.path)
478
+ if policy == "deny":
479
+ change.problems.append(
480
+ _Problem(
481
+ change.path,
482
+ "edits_denied",
483
+ "this topic does not accept agent edits; restore the file "
484
+ "(`brain restore`) and log an episode instead",
485
+ )
486
+ )
487
+ elif policy == "review":
488
+ review.append(change)
489
+ problems += change.problems
490
+ errors = [p for p in problems if p.severity == "error"]
491
+ result: dict[str, Any] = {
492
+ "brain": engine.brain.display_name,
493
+ "owner": owner,
494
+ "unchanged": unchanged,
495
+ "problems": [p.to_json() for p in problems],
496
+ }
497
+ if errors and not adopt:
498
+ result.update(
499
+ status="failed",
500
+ files=[{"path": c.path, "change": c.kind} for c in changes],
501
+ )
502
+ return result
503
+ if not changes:
504
+ result["status"] = "unchanged" if not problems else "failed"
505
+ return result
506
+ recorded = [c for c in changes if c not in review]
507
+ if review:
508
+ result["proposal"] = _propose(engine, review)
509
+ if recorded:
510
+ record = _record(engine, recorded, owner=owner)
511
+ result["run"] = record.run
512
+ result.update(
513
+ status="review" if review and not recorded else "recorded",
514
+ files=[
515
+ {
516
+ "path": c.path,
517
+ "change": c.kind,
518
+ "added_claims": c.added_claims,
519
+ "outcome": "review" if c in review else "recorded",
520
+ }
521
+ for c in changes
522
+ ],
523
+ )
524
+ return result
525
+
526
+
527
+ def _record(engine: Engine, changes: list[_Change], *, owner: bool) -> Any:
528
+ from brainwiki.runs import Run
529
+
530
+ paths = [c.path for c in changes]
531
+ run = Run(
532
+ engine,
533
+ "edit",
534
+ agent=None if owner else engine.host,
535
+ summary=f"checked edit of {', '.join(paths[:3])}" + (" …" if len(paths) > 3 else ""),
536
+ )
537
+ for change in changes:
538
+ run.adopt(change.path, change.base, change.final)
539
+ return run.commit(subject=f"brain: edit {', '.join(paths[:3])}")
540
+
541
+
542
+ def restore(engine: Engine, raw: str) -> dict[str, Any]:
543
+ """Put back a note's last recorded version. The discarded file is kept in the state dir."""
544
+ from brainwiki.config import brain_state_dir
545
+
546
+ engine.scan(force=True)
547
+ rel = _normalize_path(engine, raw)
548
+ known, base = _baseline(engine, rel)
549
+ if not known:
550
+ raise NotFound(f"no recorded version of {rel}")
551
+ current = _read(engine, rel)
552
+ if current == base:
553
+ return {"path": rel, "status": "unchanged"}
554
+ saved = None
555
+ if current is not None:
556
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
557
+ target = brain_state_dir(engine.brain.key) / "restored" / stamp / rel
558
+ target.parent.mkdir(parents=True, exist_ok=True)
559
+ target.write_bytes(current)
560
+ saved = str(target)
561
+ if base is None:
562
+ engine.store.delete(rel)
563
+ else:
564
+ engine.store.write_bytes(rel, base)
565
+ engine.scan(paths=[rel])
566
+ return {"path": rel, "status": "restored", "saved": saved, "deleted": base is None}
567
+
568
+
569
+ # -- review ----------------------------------------------------------------------------
570
+
571
+
572
+ def _propose(engine: Engine, changes: list[_Change]) -> str:
573
+ """Stage edits to topics with `agent_edits = "review"` as a proposal; restore the files."""
574
+ from brainwiki.consolidate import proposal_path
575
+ from brainwiki.format import now_iso
576
+
577
+ run = new_run_id()
578
+ rel = proposal_path(engine, run)
579
+ head = {"type": "edit-proposal", "run": run, "agent": engine.host, "created": now_iso()}
580
+ payload = {
581
+ "files": [
582
+ {
583
+ "path": c.path,
584
+ "base": sha256_bytes(c.base) if c.base is not None else None,
585
+ "content": _text(c.final) if c.final is not None else None,
586
+ }
587
+ for c in changes
588
+ ]
589
+ }
590
+ lines = ["---", *[f"{k}: {format_value(v)}" for k, v in head.items() if v], "---"]
591
+ lines += [f"# Edit proposal {run}", "", "Review needed because:", ""]
592
+ lines += sorted({f"- topic of {c.path} requires review for agent edits" for c in changes})
593
+ lines += ["", f"Accept: `brain review accept {run}` · Reject: `brain review reject {run}`", ""]
594
+ lines += ["## Diff", "", "```diff", *[_diff(c, 400).rstrip("\n") for c in changes], "```", ""]
595
+ lines += ["## Edit", "", "```json", json.dumps(payload, ensure_ascii=False), "```", ""]
596
+ engine.store.write_text(rel, "\n".join(lines))
597
+ for change in changes:
598
+ if change.base is None:
599
+ if engine.store.exists(change.path):
600
+ engine.store.delete(change.path)
601
+ else:
602
+ engine.store.write_bytes(change.path, change.base)
603
+ engine.scan(paths=[c.path for c in changes] + [rel])
604
+ return rel
605
+
606
+
607
+ def is_edit_proposal(engine: Engine, run: str) -> bool:
608
+ from brainwiki.consolidate import proposal_path
609
+
610
+ rel = proposal_path(engine, run)
611
+ if not is_prefixed(run, "run-") or not engine.store.exists(rel):
612
+ return False
613
+ return split(engine.store.read_text(rel)).data.get("type") == "edit-proposal"
614
+
615
+
616
+ def _load(engine: Engine, run: str) -> tuple[str, dict[str, Any], str | None]:
617
+ from brainwiki.consolidate import proposal_path
618
+
619
+ rel = proposal_path(engine, run)
620
+ text = engine.store.read_text(rel)
621
+ blocks = list(_JSON_BLOCK.finditer(text))
622
+ try:
623
+ payload = json.loads(blocks[-1].group("body"))
624
+ payload["files"] # noqa: B018 - validate shape
625
+ except (IndexError, ValueError, KeyError, TypeError) as exc:
626
+ raise FormatError(f"{rel}: the edit block is not valid ({exc}); nothing applied") from exc
627
+ agent = split(text).data.get("agent")
628
+ return rel, payload, str(agent) if agent else None
629
+
630
+
631
+ def _proposal_files(
632
+ engine: Engine, run: str
633
+ ) -> tuple[str, list[tuple[str, str | None, str | None]], str | None]:
634
+ """The files a proposal changes, refusing any an edit may not touch: the proposal file is
635
+ in the inbox, where anything can write it, so its payload is checked like a new edit."""
636
+ from brainwiki.index import NOTE_KINDS, classify, discovered_topics
637
+
638
+ rel, payload, agent = _load(engine, run)
639
+ topics = discovered_topics(engine)
640
+ files = []
641
+ for item in payload["files"]:
642
+ path = _normalize_path(engine, str(item.get("path", "")))
643
+ reason = _protected_reason(engine, path)
644
+ if reason is None and classify(engine, path, topics) not in NOTE_KINDS:
645
+ reason = "it is not a note"
646
+ if reason:
647
+ raise Forbidden(
648
+ f"proposal {run} would change {path}, which an edit may not touch ({reason}); "
649
+ f"reject it with `brain review reject {run}`"
650
+ )
651
+ files.append((path, item.get("base"), item.get("content")))
652
+ return rel, files, agent
653
+
654
+
655
+ def show_proposal(engine: Engine, run: str) -> str:
656
+ """What accepting the proposal would change, diffed from its payload, not its own text."""
657
+ _, files, agent = _proposal_files(engine, run)
658
+ parts = [f"Edit proposal {run} from {agent or 'an unknown agent'}. Accepting applies:"]
659
+ for path, _, content in files:
660
+ new = content.encode("utf-8") if content is not None else None
661
+ parts.append(_diff(_Change(path, _read(engine, path), new)).rstrip("\n"))
662
+ parts.append(f"Accept: brain review accept {run} Reject: brain review reject {run}")
663
+ return "\n\n".join(parts)
664
+
665
+
666
+ def accept_proposal(engine: Engine, run: str) -> Any:
667
+ from brainwiki.runs import Run
668
+
669
+ rel, files, agent = _proposal_files(engine, run)
670
+ record_run = Run(engine, "edit", agent=agent, backend="review", summary=f"accepted {run}")
671
+ for path, base, content in files:
672
+ if engine.store.sha(path) != base:
673
+ raise Conflict(f"{path} changed since the proposal; ask the agent to edit it again")
674
+ if content is None:
675
+ record_run.delete(path)
676
+ else:
677
+ record_run.write(path, content)
678
+ record_run.delete(rel)
679
+ return record_run.commit(
680
+ log_lines=[f"- accepted edit proposal {run} (agent: {agent or 'unknown'})"]
681
+ )
682
+
683
+
684
+ def reject_proposal(engine: Engine, run: str) -> Any:
685
+ from brainwiki.runs import Run
686
+
687
+ rel, _, _ = _load(engine, run)
688
+ record_run = Run(engine, "review-reject", summary=f"rejected proposal {run}")
689
+ record_run.delete(rel)
690
+ return record_run.commit(log_lines=[f"- rejected edit proposal {run}"])
691
+
692
+
693
+ def format_check_text(result: dict[str, Any]) -> str:
694
+ status = result["status"]
695
+ if status == "pending":
696
+ items = result["pending"]
697
+ if not items:
698
+ return "No pending edits: every note matches its last recorded version."
699
+ lines = [f"{len(items)} pending edit(s):"]
700
+ for item in items:
701
+ lines += [f"- {item['path']} ({item['change']})", item["diff"].rstrip("\n")]
702
+ lines.append("Record with `brain check PATH`, or put back with `brain restore PATH`.")
703
+ return "\n".join(lines)
704
+ lines = []
705
+ if status == "recorded":
706
+ lines.append(f"Recorded as {result['run']} (undo: brain undo {result['run']}).")
707
+ elif status == "review":
708
+ lines.append(f"Sent to the user for review: {result['proposal']}. The files were restored.")
709
+ elif status == "unchanged":
710
+ lines.append("Nothing to record: the files match their last recorded version.")
711
+ else:
712
+ lines.append(
713
+ "Not recorded. Fix these problems and check again, or `brain restore PATH`"
714
+ + (
715
+ "; to keep your edit as it is, `brain check --adopt PATH`:"
716
+ if result.get("owner")
717
+ else ":"
718
+ )
719
+ )
720
+ if result.get("proposal") and status == "recorded":
721
+ lines.append(f"Some edits went to review: {result['proposal']}; those files were restored.")
722
+ for item in result.get("files") or []:
723
+ if item.get("added_claims"):
724
+ lines.append(
725
+ f"- {item['path']}: {item['added_claims']} new claim(s) marked unverified (origin agent)."
726
+ )
727
+ for problem in result.get("problems") or []:
728
+ where = f"{problem['path']}:{problem['line']}" if problem["line"] else problem["path"]
729
+ lines.append(f"- {where} {problem['severity']} {problem['code']}: {problem['message']}")
730
+ return "\n".join(lines)