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,262 @@
1
+ """Portable directory snapshots with content verification and isolated restore."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import shutil
8
+ import stat
9
+ import tempfile
10
+ from pathlib import Path, PurePosixPath
11
+
12
+ from ..errors import RecoveryError
13
+ from .config import Mount, MountTable, dump_mounts, load_mounts
14
+ from .fsutil import atomic_write_text, fsync_dir
15
+ from .history import ChangeHistory, event
16
+
17
+ MANIFEST = 'backup.json'
18
+
19
+
20
+ def digest(path):
21
+ with Path(path).open('rb') as stream:
22
+ return hashlib.file_digest(stream, 'sha256').hexdigest()
23
+
24
+
25
+ def inventory(root, exclude=()):
26
+ """Do not follow symlinks, including directory links and special files."""
27
+ root = Path(root)
28
+ if root.is_symlink() or not root.is_dir():
29
+ raise RecoveryError('snapshot source must be a real directory', path=str(root))
30
+ files, directories = {}, []
31
+ def unreadable(exc):
32
+ raise RecoveryError('cannot read a snapshot directory', path=str(exc.filename)) from exc
33
+ for directory, names, filenames in os.walk(root, followlinks=False, onerror=unreadable):
34
+ current = Path(directory)
35
+ for name in sorted(names + filenames):
36
+ path = current / name
37
+ rel = path.relative_to(root).as_posix()
38
+ if any(rel == e or rel.startswith(e + '/') for e in exclude):
39
+ if name in names:
40
+ names.remove(name)
41
+ continue
42
+ mode = path.lstat().st_mode
43
+ if stat.S_ISLNK(mode) or not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)):
44
+ raise RecoveryError('snapshot contains a symlink or special file; use ordinary files/directories', path=str(path))
45
+ if stat.S_ISDIR(mode):
46
+ directories.append(rel)
47
+ else:
48
+ files[rel] = {'sha256': digest(path), 'size': path.stat().st_size}
49
+ return {'files': dict(sorted(files.items())), 'directories': sorted(directories)}
50
+
51
+
52
+ def _copy(source, target, listing):
53
+ target.mkdir(parents=True, exist_ok=True)
54
+ for name in listing['directories']:
55
+ (target / name).mkdir(parents=True, exist_ok=True)
56
+ for name, info in listing['files'].items():
57
+ src, dst = source / name, target / name
58
+ if src.is_symlink() or not stat.S_ISREG(src.stat().st_mode):
59
+ raise RecoveryError('source changed during snapshot', path=str(src))
60
+ dst.parent.mkdir(parents=True, exist_ok=True)
61
+ with src.open('rb') as reader, dst.open('xb') as stream:
62
+ shutil.copyfileobj(reader, stream)
63
+ stream.flush()
64
+ shutil.copystat(src, dst)
65
+ os.fsync(stream.fileno())
66
+ if digest(dst) != info['sha256'] or dst.stat().st_size != info['size']:
67
+ raise RecoveryError('source changed during snapshot', path=str(src))
68
+ for name in reversed(listing['directories']):
69
+ fsync_dir(target / name)
70
+ fsync_dir(target)
71
+
72
+
73
+ def _destination(path, sources):
74
+ target = Path(path).expanduser().absolute()
75
+ if target.exists() or target.is_symlink():
76
+ raise RecoveryError('destination already exists; choose a new directory', path=str(target))
77
+ for root in sources:
78
+ if target.resolve().is_relative_to(root.resolve()):
79
+ raise RecoveryError('destination must be outside the source directories', path=str(target), source=str(root))
80
+ return target
81
+
82
+
83
+ def _publish(temporary, target):
84
+ if target.exists() or target.is_symlink():
85
+ raise RecoveryError('destination appeared during operation; preserved', path=str(target))
86
+ os.rename(temporary, target)
87
+ fsync_dir(target.parent)
88
+
89
+
90
+ def _safe_relative(value):
91
+ if not isinstance(value, str) or not value or '\\' in value or ':' in value or '\x00' in value:
92
+ raise RecoveryError('invalid path in backup manifest')
93
+ path = PurePosixPath(value)
94
+ if path.is_absolute() or any(p in ('', '.', '..') for p in value.split('/')):
95
+ raise RecoveryError('unsafe path in backup manifest', path=value)
96
+ return value
97
+
98
+
99
+ def backup(ws, output):
100
+ from ..operations.audit import history_path
101
+
102
+ with ws._writing() as table:
103
+ # Recovery has completed before recording any bytes. A partial orphan log tail is not a backupable event.
104
+ _, complete = ws.history._read()
105
+ if ws.history.path.exists() and ws.history.path.stat().st_size != complete:
106
+ raise RecoveryError('history has an incomplete final record; inspect it before backing up')
107
+ roots = [ws.root]
108
+ requested = [m.root for m in table.all()]
109
+ unavailable = []
110
+ records = ws.history.events()
111
+ for e in records:
112
+ if e['event'] in ('baked', 'rolled_back'):
113
+ for field in ('path', 'previous_path'):
114
+ if e.get(field):
115
+ path = history_path(ws, e[field], records)
116
+ if path.is_dir():
117
+ requested.append(path)
118
+ else:
119
+ unavailable.append(str(path))
120
+ for root in requested:
121
+ if not root.is_dir():
122
+ raise RecoveryError('a mounted source is missing; restore its path or unmount it first', path=str(root))
123
+ if root != ws.root and ws.root.is_relative_to(root):
124
+ raise RecoveryError('a mounted source contains the workspace; mount a narrower directory before backup', path=str(root))
125
+ external = sorted(set(p for p in requested if not p.is_relative_to(ws.root)), key=lambda p: (len(p.parts), str(p)))
126
+ for root in external:
127
+ if not any(root.is_relative_to(r) for r in roots):
128
+ roots.append(root)
129
+ target = _destination(output, roots)
130
+ locations = {root: ('workspace' if root == ws.root else f'external/{i}') for i, root in enumerate(roots)}
131
+ def location(path):
132
+ root = next(r for r in roots if path.is_relative_to(r))
133
+ return (PurePosixPath(locations[root]) / path.relative_to(root).as_posix()).as_posix()
134
+ snapshots = {root: inventory(root, ('.cache',) if root == ws.root else ()) for root in roots}
135
+ if any(p in snapshots[ws.root]['files'] for p in ('staging/.apply_journal.json', '.history/.pending.json')):
136
+ raise RecoveryError('unfinished transaction; recover it before backing up')
137
+ target.parent.mkdir(parents=True, exist_ok=True)
138
+ temporary = Path(tempfile.mkdtemp(prefix='.mindslice-backup-', dir=target.parent))
139
+ try:
140
+ for root in roots:
141
+ _copy(root, temporary / locations[root], snapshots[root])
142
+ for root in roots:
143
+ if snapshots[root] != inventory(root, ('.cache',) if root == ws.root else ()):
144
+ raise RecoveryError('source changed during backup; retry after edits finish', path=str(root))
145
+ listing = inventory(temporary)
146
+ for name in list(listing['files']) + listing['directories']:
147
+ _safe_relative(name)
148
+ metadata = {'format': 'mindslice-workspace-backup', 'version': 1,
149
+ 'workspace': str(ws.root), 'roots': {str(p): locations[p] for p in roots},
150
+ 'mounts': [dict(m.to_toml(), kind=m.kind, stored=location(m.root)) for m in table.all()],
151
+ 'unavailable_versions': sorted(set(unavailable)), **listing}
152
+ atomic_write_text(temporary / MANIFEST, json.dumps(metadata, indent=2, ensure_ascii=False) + '\n')
153
+ _publish(temporary, target)
154
+ finally:
155
+ if temporary.exists():
156
+ shutil.rmtree(temporary)
157
+ return {'path': str(target), 'files': len(listing['files']), 'external_sources': len(roots) - 1,
158
+ 'unavailable_versions': metadata['unavailable_versions'],
159
+ 'note': 'Includes workspace files, staging, history, mounted external sources and available recorded versions; cache excluded.'}
160
+
161
+
162
+ def _load_backup(source):
163
+ if source.is_symlink() or (source / MANIFEST).is_symlink():
164
+ raise RecoveryError('backup and its manifest must not be symlinks')
165
+ try:
166
+ metadata = json.loads((source / MANIFEST).read_text(encoding='utf-8'))
167
+ except (OSError, ValueError) as exc:
168
+ raise RecoveryError('cannot read backup manifest', path=str(source)) from exc
169
+ if not isinstance(metadata, dict) or metadata.get('format') != 'mindslice-workspace-backup' or metadata.get('version') != 1:
170
+ raise RecoveryError('unsupported backup format')
171
+ files, directories = metadata.get('files'), metadata.get('directories')
172
+ if not isinstance(files, dict) or not isinstance(directories, list):
173
+ raise RecoveryError('invalid backup inventory')
174
+ for name in list(files) + directories:
175
+ _safe_relative(name)
176
+ for info in files.values():
177
+ if not isinstance(info, dict) or not isinstance(info.get('sha256'), str) or not isinstance(info.get('size'), int):
178
+ raise RecoveryError('invalid file checksum in backup')
179
+ actual = inventory(source, (MANIFEST,))
180
+ if actual != {'files': files, 'directories': directories}:
181
+ raise RecoveryError('backup contents do not match the manifest; no workspace restored')
182
+ if 'workspace/mounts.toml' not in files:
183
+ raise RecoveryError('backup has no workspace mount table')
184
+ if any(p in files for p in ('workspace/staging/.apply_journal.json', 'workspace/.history/.pending.json')):
185
+ raise RecoveryError('backup contains an unfinished transaction; refusing to replay old absolute paths')
186
+ roots = metadata.get('roots')
187
+ if not isinstance(roots, dict) or not isinstance(metadata.get('mounts'), list):
188
+ raise RecoveryError('invalid backup source mapping')
189
+ stored_roots = list(roots.values())
190
+ if stored_roots.count('workspace') != 1 or len(set(stored_roots)) != len(stored_roots):
191
+ raise RecoveryError('backup must contain one workspace and unique external roots')
192
+ for original, stored in roots.items():
193
+ if not isinstance(original, str):
194
+ raise RecoveryError('invalid original source path')
195
+ _safe_relative(stored)
196
+ if stored != 'workspace' and (len(PurePosixPath(stored).parts) != 2 or not stored.startswith('external/')):
197
+ raise RecoveryError('invalid stored source path')
198
+ if stored not in directories:
199
+ raise RecoveryError('missing stored source directory', path=stored)
200
+ for mount in metadata['mounts']:
201
+ if not isinstance(mount, dict):
202
+ raise RecoveryError('invalid mount record')
203
+ stored = _safe_relative(mount.get('stored'))
204
+ if not any(stored == r or stored.startswith(r + '/') for r in stored_roots) or stored not in directories:
205
+ raise RecoveryError('mount points outside stored sources', path=stored)
206
+ return metadata
207
+
208
+
209
+ def restore(source, destination):
210
+ source = Path(source).expanduser().absolute()
211
+ metadata = _load_backup(source)
212
+ target = _destination(destination, [source])
213
+ # Reserve a namespace for external sources without replacing any backed-up files.
214
+ external_dir = '.restored-mounts'
215
+ n = 1
216
+ while f'workspace/{external_dir}' in metadata['directories'] or f'workspace/{external_dir}' in metadata['files']:
217
+ n += 1
218
+ external_dir = f'.restored-mounts-{n}'
219
+ def relocated(stored):
220
+ if stored == 'workspace':
221
+ return '.'
222
+ if stored.startswith('workspace/'):
223
+ return stored[len('workspace/'):]
224
+ return external_dir + '/' + stored[len('external/'):]
225
+ target.parent.mkdir(parents=True, exist_ok=True)
226
+ temporary = Path(tempfile.mkdtemp(prefix='.mindslice-restore-', dir=target.parent))
227
+ try:
228
+ for stored in metadata['roots'].values():
229
+ prefix = stored + '/'
230
+ subset = {'files': {p[len(prefix):]: v for p, v in metadata['files'].items() if p.startswith(prefix)},
231
+ 'directories': [p[len(prefix):] for p in metadata['directories'] if p.startswith(prefix)]}
232
+ _copy(source / stored, temporary / relocated(stored), subset)
233
+ mounts = []
234
+ for entry in metadata['mounts']:
235
+ rel = relocated(entry['stored'])
236
+ mounts.append(Mount(entry['alias'], './' + rel, entry['precedence'], entry['role'], entry['kind'], temporary / rel))
237
+ overlays = [m for m in mounts if m.kind == 'overlay']
238
+ if len(overlays) != 1 or any(m.kind not in ('overlay', 'slice') for m in mounts):
239
+ raise RecoveryError('backup needs exactly one overlay')
240
+ atomic_write_text(temporary / 'mounts.toml', dump_mounts(MountTable(overlays[0], [m for m in mounts if m.kind == 'slice'])))
241
+ # Parse only the rewritten local paths, never access original paths from the manifest.
242
+ load_mounts(temporary)
243
+ history = ChangeHistory(temporary / '.history')
244
+ _, complete = history._read()
245
+ if history.path.exists() and complete != history.path.stat().st_size:
246
+ raise RecoveryError('backup history has an incomplete record')
247
+ path_map = {original: relocated(stored) for original, stored in metadata['roots'].items()}
248
+ history.append([event('restored', backup=str(source), path=str(target), path_map=path_map)])
249
+ # A reviewed bake plan is bound to the original workspace and must be reviewed again.
250
+ (temporary / '.bake-plan.json').unlink(missing_ok=True)
251
+ if _load_backup(source) != metadata:
252
+ raise RecoveryError('backup changed during restore; retry with an unchanged backup')
253
+ _publish(temporary, target)
254
+ except (KeyError, TypeError, ValueError) as exc:
255
+ raise RecoveryError('invalid backup metadata; no workspace restored') from exc
256
+ finally:
257
+ if temporary.exists():
258
+ shutil.rmtree(temporary)
259
+ return {'workspace': str(target), 'backup': str(source), 'mounts': [dict(m.to_toml(), path=str(target / m.path)) for m in mounts],
260
+ 'unavailable_versions': metadata.get('unavailable_versions', []),
261
+ 'note': 'Restored into a new directory. Overlay and staging preserved; cache rebuilds on first use. '
262
+ 'Review bake choices again and point the client at this workspace.'}
mindslice/workspace.py ADDED
@@ -0,0 +1,385 @@
1
+ """The MindSlice workspace: mount table + overlay + staging + disposable index.
2
+
3
+ All nine primitives are methods on :class:`Workspace`:
4
+
5
+ * Group A (online, also exposed over MCP): ``manifest``, ``search``, ``read``, ``write``
6
+ (plus ``boot``: the always-loaded cards, which ``manifest`` also returns)
7
+ * Group B (slice management): ``mount`` / ``unmount``, ``validate``, ``bake`` (plus ``status``)
8
+ * Group C (transactional sandbox): ``diff``, ``apply`` / ``discard``
9
+
10
+ Every operation runs under ``.cache/workspace.lock`` (``LOCK_SH`` for reads,
11
+ ``LOCK_EX`` for writes). Before serving any request the workspace finishes an
12
+ interrupted ``apply()`` (if ``staging/.apply_journal.json`` exists) and
13
+ re-indexes files whose ``(mtime_ns, size)`` changed, upgrading to ``LOCK_EX``
14
+ only when there is maintenance to do.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from contextlib import contextmanager
21
+ from pathlib import Path
22
+ from typing import Any, Iterator, Sequence
23
+
24
+ from .baking import bake as bake_mod, slice_transfer
25
+ from .operations import boot as boot_mod, staging as staging_mod, status as status_mod, validation, writer
26
+ from .retrieval import query
27
+ from .model.addressing import alias_from_name, normalize_alias
28
+ from .storage.config import (
29
+ MOUNTS_FILE,
30
+ Mount,
31
+ MountTable,
32
+ load_mounts,
33
+ load_slice_manifest,
34
+ portable_path,
35
+ save_mounts,
36
+ )
37
+ from .errors import MountError, NotFoundError, WorkspaceNotFoundError
38
+ from .storage.history import ChangeHistory
39
+ from .retrieval.index import FRONTMATTER_CODES, Index, SyncStats
40
+ from .model.lineage import FollowSpec
41
+ from .storage.locking import WorkspaceLock
42
+ from .operations.staging import StagingArea
43
+
44
+ CACHE_DIR = ".cache"
45
+ INDEX_FILE = "index.sqlite"
46
+ LOCK_FILE = "workspace.lock"
47
+ STAGING_DIR = "staging"
48
+ SLICES_DIR = "slices"
49
+
50
+
51
+ class Workspace:
52
+ def __init__(self, root: str | os.PathLike[str], *, create: bool = False, lock_timeout: float = 30.0) -> None:
53
+ self.root = Path(os.path.abspath(Path(root).expanduser()))
54
+ if not (self.root / MOUNTS_FILE).is_file():
55
+ if not create:
56
+ raise WorkspaceNotFoundError(
57
+ f"{self.root} is not a MindSlice workspace (no {MOUNTS_FILE}); run `mindslice init`", next_steps=[
58
+ "If the path is wrong, pass the right workspace with `-w PATH`; a hook or MCP configuration "
59
+ "needs the absolute path.",
60
+ f"If there is no workspace yet, create one: `mindslice init {self.root}`."])
61
+ self._bootstrap()
62
+ self.cache_dir = self.root / CACHE_DIR
63
+ self.staging = StagingArea(self.root / STAGING_DIR)
64
+ self.history = ChangeHistory(self.root / ".history")
65
+ self.lock = WorkspaceLock(self.cache_dir / LOCK_FILE, timeout=lock_timeout)
66
+ self._index: Index | None = None
67
+ self._table: MountTable | None = None
68
+ self.last_sync: SyncStats | None = None
69
+ self.last_recovery: dict[str, Any] | None = None
70
+
71
+ # ------------------------------------------------------------------
72
+ # construction helpers
73
+ # ------------------------------------------------------------------
74
+ @classmethod
75
+ def init(cls, root: str | os.PathLike[str], **kwargs: Any) -> "Workspace":
76
+ """Create (or open) a workspace with an empty overlay and default mount table."""
77
+ return cls(root, create=True, **kwargs)
78
+
79
+ @classmethod
80
+ def discover(cls, start: str | os.PathLike[str] | None = None, **kwargs: Any) -> "Workspace":
81
+ """Find the nearest enclosing workspace (directory containing ``mounts.toml``)."""
82
+ current = Path(os.path.abspath(Path(start or os.getcwd()).expanduser()))
83
+ for candidate in (current, *current.parents):
84
+ if (candidate / MOUNTS_FILE).is_file():
85
+ return cls(candidate, **kwargs)
86
+ raise WorkspaceNotFoundError(f"no {MOUNTS_FILE} found in {current} or any parent directory", next_steps=[
87
+ "Name the workspace: `mindslice -w PATH <command>`, or set `MINDSLICE_WORKSPACE=PATH`.",
88
+ "If there is no workspace yet, create one: `mindslice init PATH`."])
89
+
90
+ def _bootstrap(self) -> None:
91
+ self.root.mkdir(parents=True, exist_ok=True)
92
+ for sub in ("overlay", STAGING_DIR, SLICES_DIR, CACHE_DIR):
93
+ (self.root / sub).mkdir(exist_ok=True)
94
+ gitignore = self.root / ".gitignore"
95
+ if not gitignore.exists():
96
+ gitignore.write_text(f"{CACHE_DIR}/\n", encoding="utf-8")
97
+ table = load_mounts(self.root) # defaults: ./overlay @overlay precedence=100 role=runtime
98
+ save_mounts(self.root, table)
99
+
100
+ # ------------------------------------------------------------------
101
+ # lifecycle
102
+ # ------------------------------------------------------------------
103
+ @property
104
+ def index(self) -> Index:
105
+ if self._index is None:
106
+ self._index = Index(self.cache_dir / INDEX_FILE)
107
+ return self._index
108
+
109
+ def close(self) -> None:
110
+ if self._index is not None:
111
+ self._index.close()
112
+ self._index = None
113
+ self.lock.close()
114
+
115
+ def __enter__(self) -> "Workspace":
116
+ return self
117
+
118
+ def __exit__(self, *exc: object) -> None:
119
+ self.close()
120
+
121
+ def __repr__(self) -> str:
122
+ return f"Workspace({str(self.root)!r})"
123
+
124
+ # ------------------------------------------------------------------
125
+ # mount table / maintenance
126
+ # ------------------------------------------------------------------
127
+ def table(self) -> MountTable:
128
+ if self._table is None:
129
+ self._table = load_mounts(self.root)
130
+ return self._table
131
+
132
+ def _reload_table(self) -> MountTable:
133
+ self._table = load_mounts(self.root)
134
+ return self._table
135
+
136
+ def _exclude(self) -> list[Path]:
137
+ return [self.staging.root, self.cache_dir, self.history.root]
138
+
139
+ def _sync(self, table: MountTable | None = None, verify: bool = False) -> SyncStats:
140
+ stats = self.index.sync(table or self.table(), self._exclude(), verify=verify)
141
+ self.last_sync = stats
142
+ return stats
143
+
144
+ def _recover(self, table: MountTable) -> None:
145
+ if self.history.recover():
146
+ self.last_recovery = {"recovered": True, "history": True}
147
+ table = self._reload_table()
148
+ journal = self.staging.read_journal()
149
+ if journal is None:
150
+ return
151
+ if journal.get("corrupt"):
152
+ moved_to = self.staging.quarantine_journal()
153
+ self.last_recovery = {"recovered": False, "corrupt_journal": str(moved_to)}
154
+ return
155
+ report = staging_mod.replay(self, table, journal)
156
+ report["recovered"] = True
157
+ self.last_recovery = report
158
+
159
+ def _needs_maintenance(self, table: MountTable) -> bool:
160
+ return self.history.has_journal() or self.staging.has_journal() or self.index.is_stale(table, self._exclude())
161
+
162
+ @contextmanager
163
+ def _reading(self) -> Iterator[MountTable]:
164
+ """Shared lock; upgrades to exclusive only if a journal or stale files need handling."""
165
+ with self.lock.shared():
166
+ table = self._reload_table()
167
+ if not self._needs_maintenance(table):
168
+ yield table
169
+ return
170
+ with self.lock.exclusive():
171
+ table = self._reload_table()
172
+ self._recover(table)
173
+ table = self._reload_table()
174
+ self._sync(table)
175
+ yield table
176
+
177
+ @contextmanager
178
+ def _writing(self, sync: bool = True) -> Iterator[MountTable]:
179
+ with self.lock.exclusive():
180
+ table = self._reload_table()
181
+ self._recover(table)
182
+ table = self._reload_table()
183
+ if sync:
184
+ self._sync(table)
185
+ yield table
186
+
187
+ # ------------------------------------------------------------------
188
+ # Group A: online read/write
189
+ # ------------------------------------------------------------------
190
+ def manifest(self, limit: int = 20, offset: int = 0, max_folders_per_mount: int = 10,
191
+ include_overview: bool = False, include_always: bool = True) -> dict[str, Any]:
192
+ return query.manifest(self, limit, offset, max_folders_per_mount, include_overview, include_always)
193
+
194
+ def boot(self) -> dict[str, Any]:
195
+ """The always-loaded cards (``load = "always"``), resolved and composed, for a session's start."""
196
+ return boot_mod.boot(self)
197
+
198
+ def search(self, query_text: str, source: str | Sequence[str] | None = None,
199
+ tags: str | Sequence[str] | None = None, k: int = 10, offset: int = 0,
200
+ max_neighbors: int = 3, follow_supersedes: FollowSpec = True) -> dict[str, Any]:
201
+ return query.search(self, query_text, source, tags, k, offset, max_neighbors, follow_supersedes)
202
+
203
+ def read(self, address: str, follow_supersedes: FollowSpec = True, max_backlinks: int = 10,
204
+ backlinks_offset: int = 0, max_extensions: int = 5, extensions_offset: int = 0) -> dict[str, Any]:
205
+ return query.read(self, address, follow_supersedes, max_backlinks, backlinks_offset,
206
+ max_extensions, extensions_offset)
207
+
208
+ def write(self, id: str, op: str = "create", content: Any = None, summary: str | None = None,
209
+ title: str | None = None, tags: Sequence[str] | None = None, aliases: Sequence[str] | None = None,
210
+ extends: Sequence[str] | None = None, supersedes: Sequence[str] | None = None,
211
+ stage: bool | None = None, if_match: str | None = None, load: str | None = None, reason: str | None = None) -> dict[str, Any]:
212
+ """``stage=None`` stages changes to the always-loaded context for approval (see ``boot``)."""
213
+ return writer.write(self, id, op, content, summary, title, tags, aliases, extends, supersedes, stage,
214
+ if_match, load, reason)
215
+
216
+ # ------------------------------------------------------------------
217
+ # Group B: slice management, linting & packaging
218
+ # ------------------------------------------------------------------
219
+ def status(self) -> dict[str, Any]:
220
+ """Mounts, pending review and unbaked overlay changes, without card bodies."""
221
+ return status_mod.status(self)
222
+
223
+ def card_history(self, address: str | None = None, limit: int = 20, offset: int = 0) -> dict[str, Any]:
224
+ from .operations.audit import card_history
225
+ return card_history(self, address, limit, offset)
226
+
227
+ def explain(self, address: str, follow_supersedes=True, max_extensions: int = 5,
228
+ extensions_offset: int = 0) -> dict[str, Any]:
229
+ from .operations.audit import explain
230
+ return explain(self, address, follow_supersedes, max_extensions, extensions_offset)
231
+
232
+ def rollback(self, alias: str, path=None, *, dry_run: bool = False, revision: str | None = None) -> dict[str, Any]:
233
+ from .operations.recovery import rollback
234
+ return rollback(self, alias, path, dry_run=dry_run, revision=revision)
235
+
236
+ def backup(self, output) -> dict[str, Any]:
237
+ from .storage.snapshots import backup
238
+ return backup(self, output)
239
+
240
+ @classmethod
241
+ def restore(cls, source, destination) -> dict[str, Any]:
242
+ from .storage.snapshots import restore
243
+ return restore(source, destination)
244
+
245
+ def mount(self, path: str | os.PathLike[str], alias: str | None = None, precedence: int = 50,
246
+ role: str = "baseline", retire_overlay: bool = True) -> dict[str, Any]:
247
+ """Mount a read-only slice directory.
248
+
249
+ Relative paths are resolved against the current directory, falling back to the
250
+ workspace root. ``mounts.toml`` stores ``./relative`` paths for slices inside the workspace.
251
+
252
+ Any directory of Markdown works as is: notes without frontmatter, or with legacy ``---`` YAML,
253
+ are indexed without conversion, and without ``alias`` or a ``slice.toml`` alias the directory
254
+ name becomes one (``My Notes`` -> ``@My-Notes``).
255
+
256
+ ``retire_overlay`` (default): overlay cards this slice holds unchanged after reference
257
+ rewriting are removed so they don't shadow it. References from the overlay cards that stay
258
+ are rewritten to the slice (``slice_transfer.retire_copies``).
259
+ """
260
+ target = Path(path).expanduser()
261
+ if not target.is_absolute():
262
+ target = target.absolute() if target.is_dir() else self.root / target
263
+ target = Path(os.path.abspath(target))
264
+ if not target.is_dir():
265
+ raise MountError(f"{target} is not a directory", path=str(target))
266
+ if isinstance(precedence, bool) or not isinstance(precedence, int):
267
+ raise MountError("precedence must be an integer")
268
+ if not isinstance(role, str) or not role.strip():
269
+ raise MountError("role must be a non-empty string")
270
+ sm = load_slice_manifest(target)
271
+ if alias or sm.alias:
272
+ alias = normalize_alias(alias or sm.alias)
273
+ else: # a plain folder: its name, made a valid alias
274
+ alias = alias_from_name(target.name.removesuffix(".slice"))
275
+ with self._writing() as table:
276
+ if alias == table.overlay.alias or table.get(alias) is not None:
277
+ raise MountError(f"alias {alias} is already mounted", alias=alias, next_steps=[
278
+ f"Mount under another name with `--alias NAME`, or run `mindslice unmount {alias}` first to "
279
+ "replace that mount; `mindslice status` lists the mounts."])
280
+ for m in table.all():
281
+ if m.root == target:
282
+ raise MountError(f"{target} is already mounted as {m.alias}", alias=m.alias, next_steps=[
283
+ f"Nothing to do: its cards are available as {m.alias}. To change its alias or precedence, "
284
+ f"run `mindslice unmount {m.alias}` and mount it again."])
285
+ if target in (self.staging.root, self.cache_dir, self.history.root) or any(
286
+ root in target.parents for root in (self.cache_dir, self.history.root)):
287
+ raise MountError("cannot mount MindSlice internal directories")
288
+ new = Mount(alias, portable_path(self.root, target), precedence, role.strip(), "slice", target)
289
+ new_table = MountTable(table.overlay, [*table.mounts, new])
290
+ save_mounts(self.root, new_table)
291
+ table = self._reload_table()
292
+ stats = self._sync(table)
293
+ retired = slice_transfer.retire_copies(self, table, alias) if retire_overlay else {}
294
+ if retired:
295
+ self._sync(table)
296
+ mounted = {m.alias for m in new_table.all()}
297
+ missing = []
298
+ for dep in sm.dependencies:
299
+ try:
300
+ if normalize_alias(dep) not in mounted:
301
+ missing.append(dep)
302
+ except ValueError:
303
+ missing.append(dep)
304
+ rows = [r for r in self.index.all_cards(alias) if not r["is_overview"]]
305
+ result: dict[str, Any] = {
306
+ "alias": alias, "path": new.path, "precedence": precedence, "role": new.role,
307
+ "cards": len(rows), "sync": stats.to_dict(),
308
+ }
309
+ plain = sum(1 for r in rows if r["has_frontmatter"] == FRONTMATTER_CODES[None])
310
+ yaml = sum(1 for r in rows if r["has_frontmatter"] == FRONTMATTER_CODES["yaml"])
311
+ if plain or yaml: # indexed as is: title from the heading or file name, summary from the first paragraph
312
+ result["formats"] = {"toml": len(rows) - plain - yaml, "yaml": yaml, "plain": plain}
313
+ result.update(retired)
314
+ if missing:
315
+ result["missing_dependencies"] = missing
316
+ if sm.errors:
317
+ result["manifest_errors"] = sm.errors
318
+ always = [c for c in boot_mod.snapshot(self, self.table())["cards"]
319
+ if c["mount"] == alias or c.get("redirected_from", "").startswith(alias + "/")]
320
+ if always:
321
+ result["always_loaded"] = always
322
+ return result
323
+
324
+ def unmount(self, alias: str) -> dict[str, Any]:
325
+ alias = normalize_alias(alias)
326
+ with self._writing() as table:
327
+ if alias == table.overlay.alias:
328
+ raise MountError(f"{alias} is the writable overlay and cannot be unmounted")
329
+ if table.get(alias) is None:
330
+ raise NotFoundError(f"{alias} is not mounted", alias=alias)
331
+ new_table = MountTable(table.overlay, [m for m in table.mounts if m.alias != alias])
332
+ save_mounts(self.root, new_table)
333
+ stats = self._sync(self._reload_table())
334
+ return {"alias": alias, "removed_cards": stats.removed, "sync": stats.to_dict()}
335
+
336
+ def validate(self, slice_path: str | os.PathLike[str] | None = None, rebuild_index: bool = False) -> dict[str, Any]:
337
+ return validation.validate(self, slice_path, rebuild_index)
338
+
339
+ def bake(self, alias: str | None = None, output_path: str | os.PathLike[str] | None = None, prefix: str | None = None,
340
+ tags: str | Sequence[str] | None = None, include_deps: bool = False, dry_run: bool = False,
341
+ *, name: str | None = None, description: str | None = None, version: str | None = None,
342
+ plan: dict[str, Any] | None = None) -> dict[str, Any]:
343
+ """Bake one alias, or a reviewed workspace plan when ``alias`` is omitted.
344
+
345
+ Named new slices require ``prefix``; batch plans explicitly assign individual cards.
346
+ ``bake(dry_run=True)`` returns the batch plan; ``bake(plan=reviewed)`` verifies and executes it.
347
+ Previous directories remain untouched. Batch outputs use workspace ``slices/`` defaults.
348
+ """
349
+ if alias is None:
350
+ from .baking import bake_batch
351
+ from .errors import InvalidArgumentsError
352
+ if any(x is not None for x in (output_path, prefix, tags, name, description, version)) or include_deps:
353
+ raise InvalidArgumentsError("single-slice options require an alias")
354
+ reviewed = self.plan_bake() if plan is None else plan
355
+ if dry_run:
356
+ return reviewed if plan is None else bake_batch.inspect_plan(self, reviewed)
357
+ return bake_batch.execute(self, reviewed)
358
+ if plan is not None:
359
+ from .errors import InvalidArgumentsError
360
+ raise InvalidArgumentsError("a workspace bake plan cannot be combined with an alias")
361
+ return bake_mod.bake(self, alias, output_path, prefix, tags, include_deps, dry_run,
362
+ name=name, description=description, version=version)
363
+
364
+ def plan_bake(self, assignments: dict[str, str | None] | None = None, *, resume: bool = True) -> dict[str, Any]:
365
+ """Review all overlay cards; null destinations explicitly defer cards. No content writes."""
366
+ from .baking.bake_batch import plan_bake
367
+ return plan_bake(self, assignments, resume=resume)
368
+
369
+ # ------------------------------------------------------------------
370
+ # Group C: transactional sandbox
371
+ # ------------------------------------------------------------------
372
+ def diff(self, scope: str = "staging") -> dict[str, Any]:
373
+ return staging_mod.diff(self, scope)
374
+
375
+ def apply(self, ids: list[str] | None = None) -> dict[str, Any]:
376
+ """Accept all pending changes, or only the specified staging card IDs."""
377
+ return staging_mod.apply(self, ids)
378
+
379
+ def discard(self, ids: list[str] | None = None) -> dict[str, Any]:
380
+ """Reject all pending changes, or only the specified staging card IDs."""
381
+ return staging_mod.discard(self, ids)
382
+
383
+ def review(self, accept: list[str], reject: list[str], *, revision: str, dry_run: bool = False) -> dict[str, Any]:
384
+ """Execute reviewed choices in one recoverable batch; other cards stay pending."""
385
+ return staging_mod.apply(self, accept, reject=reject, revision=revision, dry_run=dry_run)