mindslice 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. mindslice/__init__.py +58 -0
  2. mindslice/__main__.py +5 -0
  3. mindslice/baking/__init__.py +1 -0
  4. mindslice/baking/archive.py +66 -0
  5. mindslice/baking/bake.py +299 -0
  6. mindslice/baking/bake_batch.py +537 -0
  7. mindslice/baking/slice_transfer.py +265 -0
  8. mindslice/errors.py +104 -0
  9. mindslice/interfaces/__init__.py +1 -0
  10. mindslice/interfaces/bake_review.py +230 -0
  11. mindslice/interfaces/cli.py +392 -0
  12. mindslice/interfaces/cli_addressing.py +52 -0
  13. mindslice/interfaces/doctor.py +184 -0
  14. mindslice/interfaces/mcp_server.py +536 -0
  15. mindslice/interfaces/render.py +346 -0
  16. mindslice/interfaces/staging_review.py +92 -0
  17. mindslice/model/__init__.py +1 -0
  18. mindslice/model/addressing.py +251 -0
  19. mindslice/model/card.py +641 -0
  20. mindslice/model/lineage.py +228 -0
  21. mindslice/model/text.py +19 -0
  22. mindslice/model/tomlfmt.py +129 -0
  23. mindslice/operations/__init__.py +1 -0
  24. mindslice/operations/audit.py +195 -0
  25. mindslice/operations/boot.py +183 -0
  26. mindslice/operations/impact.py +98 -0
  27. mindslice/operations/recovery.py +113 -0
  28. mindslice/operations/staging.py +641 -0
  29. mindslice/operations/status.py +137 -0
  30. mindslice/operations/validation.py +250 -0
  31. mindslice/operations/writer.py +481 -0
  32. mindslice/retrieval/__init__.py +1 -0
  33. mindslice/retrieval/index.py +634 -0
  34. mindslice/retrieval/lexical.py +215 -0
  35. mindslice/retrieval/query.py +593 -0
  36. mindslice/storage/__init__.py +1 -0
  37. mindslice/storage/card_io.py +14 -0
  38. mindslice/storage/config.py +192 -0
  39. mindslice/storage/fsutil.py +141 -0
  40. mindslice/storage/history.py +182 -0
  41. mindslice/storage/locking.py +125 -0
  42. mindslice/storage/snapshots.py +262 -0
  43. mindslice/workspace.py +385 -0
  44. mindslice-0.1.0.dist-info/METADATA +98 -0
  45. mindslice-0.1.0.dist-info/RECORD +50 -0
  46. mindslice-0.1.0.dist-info/WHEEL +5 -0
  47. mindslice-0.1.0.dist-info/entry_points.txt +2 -0
  48. mindslice-0.1.0.dist-info/licenses/LICENSE +202 -0
  49. mindslice-0.1.0.dist-info/licenses/NOTICE +22 -0
  50. mindslice-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,481 @@
1
+ """``write()``: create / replace / append / delete cards in ``overlay/`` or ``staging/``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from typing import TYPE_CHECKING, Any, Iterable, Sequence
7
+
8
+ from ..model.addressing import MountCatalog, format_address, normalize_alias, normalize_path, resolve_ref
9
+ from ..model.card import (
10
+ LIST_FIELDS,
11
+ LOAD_ALWAYS,
12
+ LOAD_VALUES,
13
+ ParsedCard,
14
+ append_statements,
15
+ compose_after,
16
+ normalize_newlines,
17
+ parse_card,
18
+ render_card,
19
+ reassemble,
20
+ sha256_bytes,
21
+ split_summary,
22
+ statement_blocks,
23
+ )
24
+ from ..errors import (
25
+ AlreadyExistsError,
26
+ FrontmatterError,
27
+ InvalidAddressError,
28
+ LineageCycleError,
29
+ InvalidArgumentsError,
30
+ NotFoundError,
31
+ PreconditionFailedError,
32
+ ReadOnlyMountError,
33
+ )
34
+ from ..storage.history import change, file_action
35
+ from ..storage.card_io import read_card_file
36
+ from .impact import inbound_impact, supersede_targets
37
+ from ..model.lineage import cycle_through
38
+ from ..baking.slice_transfer import rewrite_refs
39
+
40
+ if TYPE_CHECKING: # pragma: no cover
41
+ from ..workspace import Workspace
42
+
43
+ OPS = ("create", "replace", "append", "delete")
44
+
45
+
46
+ def overlay_card_path(ws: "Workspace", card_id: str, strict: bool = True) -> str:
47
+ """Normalize a write target to an overlay-relative path (``@overlay/`` prefix allowed).
48
+
49
+ ``strict`` (for a new card) also requires a link-safe id; ``replace`` / ``append`` / ``delete``
50
+ accept any existing file name, such as a note a human dropped into ``overlay/``.
51
+ """
52
+ if not isinstance(card_id, str) or not card_id.strip():
53
+ raise InvalidAddressError("write() needs a non-empty card id")
54
+ card_id = card_id.strip()
55
+ overlay_alias = ws.table().overlay.alias
56
+ if card_id.startswith("@"):
57
+ alias, _, rest = card_id.partition("/")
58
+ if normalize_alias(alias) != overlay_alias:
59
+ raise ReadOnlyMountError(
60
+ f"{alias} is a read-only base slice; runtime writes go to {overlay_alias}",
61
+ alias=alias,
62
+ next_steps=[
63
+ f"To change a card of {alias}, write to the same path without the alias ({rest or 'folder/card'!r}): "
64
+ f"replace and append save the edited copy in {overlay_alias} as its replacement, and the slice "
65
+ "stays as it is until the next bake."],
66
+ )
67
+ card_id = rest
68
+ return normalize_path(card_id, strict=strict)
69
+
70
+
71
+ def _as_list(name: str, value: Any) -> list[str] | None:
72
+ if value is None:
73
+ return None
74
+ if isinstance(value, str):
75
+ value = [value]
76
+ if not isinstance(value, (list, tuple)) or not all(isinstance(v, str) for v in value):
77
+ raise FrontmatterError(f"{name} must be a list of strings")
78
+ return [v.strip() for v in value if v.strip()]
79
+
80
+
81
+ def _body_text(content: Any) -> str | None:
82
+ if content is None:
83
+ return None
84
+ if isinstance(content, str):
85
+ return normalize_newlines(content)
86
+ if isinstance(content, (list, tuple)):
87
+ return "\n".join(statement_blocks(content))
88
+ raise TypeError("content must be a string or a list of statements")
89
+
90
+
91
+ def humanize(path: str) -> str:
92
+ leaf = path.rsplit("/", 1)[-1]
93
+ words = leaf.replace("_", " ").replace("-", " ").split()
94
+ return " ".join(w[:1].upper() + w[1:] for w in words) or path
95
+
96
+
97
+ def _union(existing: Iterable[str], extra: Iterable[str]) -> list[str]:
98
+ out = list(dict.fromkeys(existing))
99
+ for item in extra:
100
+ if item not in out:
101
+ out.append(item)
102
+ return out
103
+
104
+
105
+ def _set_load(fields: dict[str, Any], load: str | None) -> None:
106
+ """``load="always"`` writes the field; ``"as-needed"`` (the default) removes it."""
107
+ if load == LOAD_ALWAYS:
108
+ fields["load"] = LOAD_ALWAYS
109
+ elif load is not None:
110
+ fields.pop("load", None)
111
+
112
+
113
+ def build_text(
114
+ op: str,
115
+ path: str,
116
+ current: str | None,
117
+ *,
118
+ content: Any,
119
+ summary: str | None,
120
+ title: str | None,
121
+ tags: list[str] | None,
122
+ aliases: list[str] | None,
123
+ extends: list[str] | None,
124
+ supersedes: list[str] | None,
125
+ load: str | None = None,
126
+ ) -> tuple[str | None, dict[str, Any]]:
127
+ """Compute the new card text for ``op`` (None for delete); returns ``(text, info)``."""
128
+ info: dict[str, Any] = {}
129
+ lists = {"tags": tags, "aliases": aliases, "extends": extends, "supersedes": supersedes}
130
+ if summary is not None:
131
+ summary = " ".join(normalize_newlines(summary).split())
132
+ if op == "create":
133
+ fields: dict[str, Any] = {"title": title.strip() if title else humanize(path)}
134
+ fields.update({k: v or [] for k, v in lists.items()})
135
+ _set_load(fields, load)
136
+ return render_card(fields, summary, _body_text(content)), info
137
+ if op == "delete":
138
+ return None, info
139
+ assert current is not None
140
+ parsed = parse_card(current)
141
+ if op == "replace":
142
+ given = {k: v for k, v in lists.items() if v is not None}
143
+ new_fields: dict[str, Any] | None = None
144
+ if title is not None or given or load is not None:
145
+ new_fields = dict(parsed.frontmatter) if not parsed.errors else {}
146
+ if title is not None:
147
+ new_fields["title"] = title.strip()
148
+ elif not parsed.title:
149
+ new_fields["title"] = parsed.display_title(path)
150
+ new_fields.update(given)
151
+ _set_load(new_fields, load)
152
+ after = None
153
+ body = _body_text(content)
154
+ if summary is not None or body is not None:
155
+ after = compose_after(parsed.summary if summary is None else summary, parsed.body if body is None else body)
156
+ return reassemble(parsed, current, new_fields, after), info
157
+ if op == "append":
158
+ if content is None and not any(v for v in lists.values()) and title is None and summary is None and load is None:
159
+ raise ValueError("append needs content (bullet statements) or list fields to merge")
160
+ blocks = statement_blocks(content) if content is not None else []
161
+ new_after, added = append_statements(parsed.after, blocks)
162
+ info["added_statements"] = len(added)
163
+ info["duplicate_statements"] = len(blocks) - len(added)
164
+ if summary is not None:
165
+ _, body = split_summary(new_after)
166
+ new_after = compose_after(summary, body)
167
+ new_fields = None
168
+ if title is not None or any(v for v in lists.values()) or load is not None:
169
+ new_fields = dict(parsed.frontmatter) if not parsed.errors else {}
170
+ if title is not None:
171
+ new_fields["title"] = title.strip()
172
+ elif not parsed.title:
173
+ new_fields["title"] = parsed.display_title(path)
174
+ for name, extra in lists.items():
175
+ if extra:
176
+ new_fields[name] = _union(parsed.list_field(name), extra)
177
+ _set_load(new_fields, load)
178
+ if new_fields == parsed.frontmatter:
179
+ new_fields = None
180
+ changed_after = new_after if new_after != parsed.after else None
181
+ return reassemble(parsed, current, new_fields, changed_after), info
182
+ raise ValueError(f"unknown op {op!r} (expected one of {', '.join(OPS)})")
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Cycle checks over the prospective lineage graph
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ def prospective_successors(
191
+ index, overlay_alias: str, overrides: dict[str, ParsedCard | None]
192
+ ) -> tuple[dict[str, list[str]], dict[str, MountCatalog]]:
193
+ """Lineage successor map (``extends`` + ``supersedes``) with overlay overrides applied."""
194
+ cats = index.catalogs()
195
+ ocat = cats.setdefault(overlay_alias, MountCatalog())
196
+ for path, parsed in overrides.items():
197
+ ocat.remove(path)
198
+ for path, parsed in overrides.items():
199
+ if parsed is not None:
200
+ ocat.add(path, parsed.aliases)
201
+ refs: list[tuple[str, str, str]] = []
202
+ for row in index.lineage_edges():
203
+ if row["src_mount"] == overlay_alias and row["src_path"] in overrides:
204
+ continue
205
+ refs.append((row["src_mount"], row["src_path"], row["raw"]))
206
+ for path, parsed in overrides.items():
207
+ if parsed is not None:
208
+ refs += [(overlay_alias, path, raw) for raw in parsed.extends + parsed.supersedes]
209
+ succ: dict[str, list[str]] = defaultdict(list)
210
+ for src_mount, src_path, raw in refs:
211
+ res = resolve_ref(raw, src_mount, cats)
212
+ if res.resolved and res.address:
213
+ succ[format_address(src_mount, src_path)].append(res.address)
214
+ return succ, cats
215
+
216
+
217
+ def check_cycles(index, overlay_alias: str, overrides: dict[str, ParsedCard | None], nodes: Sequence[str]) -> None:
218
+ succ, _ = prospective_successors(index, overlay_alias, overrides)
219
+ for path in nodes:
220
+ if overrides.get(path) is None:
221
+ continue
222
+ start = format_address(overlay_alias, path)
223
+ cycle = cycle_through(start, lambda n: succ.get(n, ()))
224
+ if cycle:
225
+ raise LineageCycleError(
226
+ f"lineage cycle via {start}: {' -> '.join(cycle)}", cycle=cycle, address=start
227
+ )
228
+
229
+
230
+ def unresolved_lineage(parsed: ParsedCard, overlay_alias: str, cats: dict[str, MountCatalog]) -> list[str]:
231
+ out = []
232
+ for kind, refs in (("extends", parsed.extends), ("supersedes", parsed.supersedes)):
233
+ for raw in refs:
234
+ if not resolve_ref(raw, overlay_alias, cats).resolved:
235
+ out.append(f"{kind} target {raw!r} does not resolve (yet)")
236
+ return out
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # Editing a card that lives in a read-only slice (copy-on-write)
241
+ # ---------------------------------------------------------------------------
242
+
243
+
244
+ def mounted_base(ws: "Workspace", table, path: str) -> tuple[str, str, str] | None:
245
+ """``(address, text, sha256)`` of the read-only card ``read(path)`` selects when the overlay has none."""
246
+ for row in ws.index.cards_at_path(path):
247
+ mount = table.get(row["mount"])
248
+ if mount is None or mount.alias == table.overlay.alias:
249
+ continue
250
+ text, sha = read_card_file(mount.root / f"{path}.md")
251
+ if text is not None and sha is not None:
252
+ return format_address(mount.alias, path), text, sha
253
+ return None
254
+
255
+
256
+ def fork_seed(text: str, base_address: str, cats: dict[str, MountCatalog]) -> str:
257
+ """A mounted card's text as it reads from ``@overlay``: its slice-relative references made qualified.
258
+
259
+ Inside a slice ``[[other]]`` means the slice's own card; copied to the overlay unchanged it would point
260
+ at an overlay card of that name instead.
261
+ """
262
+ alias = base_address.partition("/")[0]
263
+
264
+ def qualify(raw: str) -> str:
265
+ if raw.strip().startswith("@"):
266
+ return raw
267
+ res = resolve_ref(raw, alias, cats)
268
+ return res.address if res.resolved and res.address else raw
269
+
270
+ return rewrite_refs(text, parse_card(text), qualify)
271
+
272
+
273
+ def _superseding(text: str, path: str, base_address: str, overlay_alias: str, cats: dict[str, MountCatalog]) -> str:
274
+ """``text`` with ``base_address`` among its ``supersedes``, so hits and reads of the base forward to it."""
275
+ parsed = parse_card(text)
276
+ if any(resolve_ref(raw, overlay_alias, cats).address == base_address for raw in parsed.supersedes):
277
+ return text
278
+ fields = dict(parsed.frontmatter) if not parsed.errors else {}
279
+ if not parsed.title:
280
+ fields["title"] = parsed.display_title(path)
281
+ fields["supersedes"] = [*parsed.supersedes, base_address]
282
+ return reassemble(parsed, text, fields, None)
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # write()
287
+ # ---------------------------------------------------------------------------
288
+
289
+
290
+ def write_impact(ws: "Workspace", table, path: str, parsed: ParsedCard | None, *, stage: bool,
291
+ deleting: bool, dangling: bool = False) -> dict[str, Any]:
292
+ """``affected_backlinks`` / ``affected_extensions`` for the cards this write retires.
293
+
294
+ Retired cards are the card itself for a delete, else the cards it ``supersedes`` (all of them, on
295
+ the state the write leaves, so a retry reports the same). Empty for a write that supersedes
296
+ nothing. Computed under the caller's ``LOCK_EX``.
297
+ """
298
+ if not deleting and (parsed is None or not parsed.supersedes):
299
+ return {}
300
+ overlay = table.overlay
301
+ address = format_address(overlay.alias, path)
302
+ overrides: dict[str, ParsedCard | None] = {}
303
+ cats: dict[str, MountCatalog] | None = None
304
+ if stage and not dangling:
305
+ overrides = ws.staging.prospective_overrides(overlay.root)
306
+ _, cats = prospective_successors(ws.index, overlay.alias, overrides)
307
+ if deleting:
308
+ targets = [address]
309
+ elif cats is not None:
310
+ targets = supersede_targets(parsed, overlay.alias, cats)
311
+ else: # a live write: the index is synced, so its resolved edges are the card's
312
+ targets = list(dict.fromkeys(format_address(l["dst_mount"], l["dst_path"])
313
+ for l in ws.index.outbound(overlay.alias, path, "supersedes") if l["resolved"]))
314
+ if not targets:
315
+ return {}
316
+ return inbound_impact(ws.index, overlay.alias, overlay.precedence, targets, overrides=overrides,
317
+ cats=cats, exclude=[address], dangling=dangling)
318
+
319
+
320
+ def write(
321
+ ws: "Workspace",
322
+ id: str,
323
+ op: str = "create",
324
+ content: Any = None,
325
+ summary: str | None = None,
326
+ title: str | None = None,
327
+ tags: Sequence[str] | None = None,
328
+ aliases: Sequence[str] | None = None,
329
+ extends: Sequence[str] | None = None,
330
+ supersedes: Sequence[str] | None = None,
331
+ stage: bool | None = None,
332
+ if_match: str | None = None,
333
+ load: str | None = None,
334
+ reason: str | None = None,
335
+ ) -> dict[str, Any]:
336
+ """``stage=None`` (the default) stages a change to the always-loaded context for the user's
337
+ approval and writes anything else straight to ``overlay/``; ``True`` / ``False`` force either."""
338
+ from .boot import protection_reason # evaluate protection against the current workspace
339
+
340
+ if reason is not None:
341
+ if not isinstance(reason, str) or not reason.strip():
342
+ raise InvalidArgumentsError("reason must be a non-empty string when supplied")
343
+ reason = reason.strip()
344
+ if op not in OPS:
345
+ raise ValueError(f"unknown op {op!r} (expected one of {', '.join(OPS)})")
346
+ if load is not None and load not in LOAD_VALUES:
347
+ raise ValueError(f"load must be {' or '.join(repr(v) for v in LOAD_VALUES)} (got {load!r})")
348
+ path = overlay_card_path(ws, id, strict=op == "create")
349
+ tags_l = _as_list("tags", tags)
350
+ aliases_l = _as_list("aliases", aliases)
351
+ extends_l = _as_list("extends", extends)
352
+ supersedes_l = _as_list("supersedes", supersedes)
353
+ with ws._writing() as table:
354
+ overlay = table.overlay
355
+ address = format_address(overlay.alias, path)
356
+ staging = ws.staging
357
+ approval = None # why this change needs the user's approval (it touches the always-loaded context)
358
+ live_text = read_card_file(overlay.root / f"{path}.md")[0]
359
+ staged_text = staging.prospective_state(path, overlay.root)[0]
360
+ # replace / append of a card that exists only in a read-only slice (after a bake, say) edits a copy:
361
+ # the result is saved in the overlay as the card's replacement and the slice is left as it is.
362
+ base = None
363
+ if op in ("replace", "append") and live_text is None and staged_text is None:
364
+ base = mounted_base(ws, table, path)
365
+ if stage is not False:
366
+ texts = [live_text, staged_text]
367
+ lineage = [("extends", r) for r in extends_l or []] + [("supersedes", r) for r in supersedes_l or []]
368
+ if base is not None:
369
+ texts.append(base[1])
370
+ lineage.append(("supersedes", base[0]))
371
+ approval = protection_reason(ws, table, path, texts, load, lineage)
372
+ if stage is None:
373
+ stage = approval is not None
374
+ if stage:
375
+ current, current_sha = staging.prospective_state(path, overlay.root)
376
+ else:
377
+ current, current_sha = read_card_file(overlay.root / f"{path}.md")
378
+ result: dict[str, Any] = {
379
+ "address": address,
380
+ "id": path,
381
+ "op": op,
382
+ "staged": bool(stage),
383
+ "changed": False,
384
+ "previous_sha256": current_sha,
385
+ "warnings": [],
386
+ }
387
+ if reason is not None:
388
+ result["reason"] = reason
389
+ match_sha = base[2] if base is not None else current_sha # read(path) returned the base's sha256
390
+ if if_match is not None and if_match != match_sha:
391
+ raise PreconditionFailedError(
392
+ f"{address} changed since it was read (if_match={if_match[:12]}…, current="
393
+ f"{match_sha[:12] + '…' if match_sha else 'absent'})",
394
+ address=address,
395
+ expected=if_match,
396
+ current=match_sha,
397
+ )
398
+ if op == "create" and current is not None:
399
+ new_text, _ = build_text(
400
+ op, path, None, content=content, summary=summary, title=title, tags=tags_l,
401
+ aliases=aliases_l, extends=extends_l, supersedes=supersedes_l, load=load,
402
+ )
403
+ if new_text == normalize_newlines(current):
404
+ result["sha256"] = current_sha
405
+ result.update(write_impact(ws, table, path, parse_card(current), stage=stage, deleting=False))
406
+ return result # idempotent retry
407
+ raise AlreadyExistsError(f"{address} already exists with different content", address=address, sha256=current_sha)
408
+ if op in ("replace", "append") and current is None and base is None:
409
+ raise NotFoundError(f"{address} does not exist{' in staging or overlay' if stage else ''}", address=address)
410
+ if op == "delete" and current is None:
411
+ result["sha256"] = None
412
+ result["warnings"].append(f"{address} does not exist; nothing to delete")
413
+ result.update(write_impact(ws, table, path, None, stage=stage, deleting=True, dangling=not stage))
414
+ if stage:
415
+ staging.stage_delete(path, overlay.root, audit=ws.history) # clears stale entries, if any
416
+ return result
417
+
418
+ seed = fork_seed(base[1], base[0], ws.index.catalogs()) if base is not None else current
419
+ new_text, info = build_text(
420
+ op, path, seed, content=content, summary=summary, title=title, tags=tags_l,
421
+ aliases=aliases_l, extends=extends_l, supersedes=supersedes_l, load=load,
422
+ )
423
+ result.update(info)
424
+ if base is not None:
425
+ assert new_text is not None and seed is not None
426
+ if new_text == normalize_newlines(seed): # nothing to change: no copy is made
427
+ result["address"], result["sha256"] = base[0], base[2]
428
+ return result
429
+ new_text = _superseding(new_text, path, base[0], overlay.alias, ws.index.catalogs())
430
+ result["forked_from"] = base[0]
431
+ parsed_new: ParsedCard | None = None
432
+ if new_text is not None:
433
+ parsed_new = parse_card(new_text)
434
+ if parsed_new.errors:
435
+ raise FrontmatterError("; ".join(parsed_new.errors), address=address)
436
+ overrides: dict[str, ParsedCard | None] = {}
437
+ if stage:
438
+ overrides.update(staging.prospective_overrides(overlay.root))
439
+ overrides[path] = parsed_new
440
+ check_cycles(ws.index, overlay.alias, overrides, [path])
441
+ _, cats = prospective_successors(ws.index, overlay.alias, overrides)
442
+ result["warnings"] += unresolved_lineage(parsed_new, overlay.alias, cats)
443
+
444
+ if new_text is not None and current is not None and new_text == normalize_newlines(current):
445
+ result["sha256"] = current_sha
446
+ result.update(write_impact(ws, table, path, parsed_new, stage=stage, deleting=False))
447
+ return result
448
+
449
+ # A delete's impact is read before the card goes (its inbound references still resolve).
450
+ impact = write_impact(ws, table, path, None, stage=stage, deleting=True) if new_text is None else None
451
+ change_event = change("proposed" if stage else "applied", address=address, op=op,
452
+ reason=reason, before_sha256=current_sha,
453
+ after_sha256=sha256_bytes(new_text.encode("utf-8")) if new_text is not None else None,
454
+ origin="write")
455
+ result["change_id"] = change_event["change_id"]
456
+ if stage:
457
+ if new_text is None:
458
+ staging.stage_delete(path, overlay.root, requires_approval=approval, audit=ws.history, event=change_event)
459
+ else:
460
+ staging.stage_write(path, new_text, overlay.root, op, requires_approval=approval, audit=ws.history, event=change_event)
461
+ if approval:
462
+ result["requires_approval"] = approval
463
+ result["warnings"].append(
464
+ f"Staged, not applied, because {approval}. Always-loaded cards shape every session, so the user "
465
+ "approves changes to them: tell the user, who can review it with `mindslice diff` and approve it "
466
+ "with `mindslice apply` (or drop it with `mindslice discard`)."
467
+ )
468
+ else:
469
+ file = overlay.root / f"{path}.md"
470
+ ws.history.transact([file_action(file, new_text, overlay.root)], [change_event])
471
+ ws._sync(table)
472
+ result["changed"] = True
473
+ result["sha256"] = sha256_bytes(new_text.encode("utf-8")) if new_text is not None else None
474
+ if impact is None:
475
+ impact = write_impact(ws, table, path, parsed_new, stage=stage, deleting=False)
476
+ result.update(impact)
477
+ return result
478
+
479
+
480
+ __all__ = ["write", "write_impact", "build_text", "check_cycles", "prospective_successors", "OPS",
481
+ "LIST_FIELDS"]
@@ -0,0 +1 @@
1
+ """SQLite indexing, lexical analysis and memory queries."""