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,228 @@
1
+ """Lineage semantics: trust policies, supersession forwarding and cycle detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from dataclasses import dataclass, field
7
+ from typing import Callable, Hashable, Iterable, Sequence, TypeVar
8
+
9
+ from .addressing import format_address, normalize_alias
10
+ from ..errors import InvalidAddressError
11
+
12
+ N = TypeVar("N", bound=Hashable)
13
+ FollowSpec = bool | Sequence[str] | None
14
+
15
+
16
+ class TrustPolicy:
17
+ """Which ``supersedes`` edges may be followed automatically.
18
+
19
+ * ``True`` - every active replacement is trusted (default)
20
+ * ``False`` - never forward; replacements are only reported
21
+ * ``["curated", "@team"]`` - trust replacements whose mount ``role`` or ``@alias`` is listed
22
+ """
23
+
24
+ def __init__(self, follow: FollowSpec = True) -> None:
25
+ if follow is None:
26
+ follow = True
27
+ if isinstance(follow, str):
28
+ follow = [follow]
29
+ self.all = follow is True
30
+ self.none = follow is False
31
+ self.allow: set[str] = set()
32
+ if not isinstance(follow, bool):
33
+ for item in follow:
34
+ item = str(item).strip()
35
+ if not item:
36
+ continue
37
+ if item.startswith("@"):
38
+ try:
39
+ item = normalize_alias(item)
40
+ except InvalidAddressError:
41
+ pass
42
+ self.allow.add(item)
43
+
44
+ def trusts(self, mount: str, role: str) -> bool:
45
+ if self.all:
46
+ return True
47
+ if self.none:
48
+ return False
49
+ return mount in self.allow or role in self.allow
50
+
51
+
52
+ @dataclass
53
+ class Forward:
54
+ """Result of following trusted ``supersedes`` edges from a starting node.
55
+
56
+ ``node`` is where a read lands: the unique terminal replacement, or - when
57
+ replacements compete - the fork point reached by following the unambiguous
58
+ part of the chain (``start`` itself if it is directly contested or on a cycle).
59
+ """
60
+
61
+ start: str
62
+ node: str
63
+ terminals: list[str] = field(default_factory=list)
64
+ path: list[str] = field(default_factory=list) # start -> ... -> node
65
+ superseded: set[str] = field(default_factory=set) # nodes passed through before ``node``
66
+ competing: bool = False
67
+ cycle: bool = False
68
+
69
+ @property
70
+ def redirected(self) -> bool:
71
+ return self.node != self.start
72
+
73
+ @property
74
+ def terminal(self) -> str | None:
75
+ return None if (self.competing or self.cycle) else self.node
76
+
77
+
78
+ class LineageView:
79
+ """Per-request lineage queries over an index (memoized)."""
80
+
81
+ def __init__(self, index, policy: TrustPolicy) -> None:
82
+ self.index = index
83
+ self.policy = policy
84
+ self._superseders: dict[str, list[tuple[str, bool, int, int]]] = {}
85
+
86
+ @staticmethod
87
+ def split(address: str) -> tuple[str, str]:
88
+ mount, _, path = address.partition("/")
89
+ return mount, path
90
+
91
+ def superseders(self, address: str) -> list[tuple[str, bool, int, int]]:
92
+ """Direct active inbound ``supersedes`` sources: ``(address, trusted, precedence, mtime_ns)``."""
93
+ if address not in self._superseders:
94
+ mount, path = self.split(address)
95
+ rows = self.index.inbound("supersedes", mount, path)
96
+ self._superseders[address] = [
97
+ (
98
+ format_address(r["src_mount"], r["src_path"]),
99
+ self.policy.trusts(r["src_mount"], r["role"]),
100
+ r["precedence"],
101
+ r["mtime_ns"],
102
+ )
103
+ for r in rows
104
+ ]
105
+ return self._superseders[address]
106
+
107
+ def trusted_superseders(self, address: str) -> list[str]:
108
+ return [a for a, trusted, _, _ in self.superseders(address) if trusted]
109
+
110
+ def untrusted_superseders(self, address: str) -> list[str]:
111
+ return [a for a, trusted, _, _ in self.superseders(address) if not trusted]
112
+
113
+ def all_superseders(self, address: str) -> list[str]:
114
+ return [a for a, _, _, _ in self.superseders(address)]
115
+
116
+ def forward(self, start: str) -> Forward:
117
+ parent: dict[str, str] = {}
118
+ seen = {start}
119
+ queue = deque([start])
120
+ order: dict[str, tuple[int, int]] = {}
121
+ while queue:
122
+ node = queue.popleft()
123
+ for addr, trusted, precedence, mtime in self.superseders(node):
124
+ if not trusted or addr in seen:
125
+ continue
126
+ seen.add(addr)
127
+ parent[addr] = node
128
+ order[addr] = (precedence, mtime)
129
+ queue.append(addr)
130
+ reached = seen - {start}
131
+ if not reached:
132
+ return Forward(start, start, [start], [start], set())
133
+ terminals = [n for n in reached if not self.trusted_superseders(n)]
134
+ terminals.sort(key=lambda a: (-order[a][0], a))
135
+ if not terminals:
136
+ return Forward(start, start, [], [start], set(), cycle=True)
137
+ if len(terminals) > 1:
138
+ # Follow the chain while it is unambiguous; stop at the fork.
139
+ path = [start]
140
+ while True:
141
+ nxt = self.trusted_superseders(path[-1])
142
+ if len(nxt) != 1 or nxt[0] in path:
143
+ break
144
+ path.append(nxt[0])
145
+ return Forward(start, path[-1], terminals, path, set(path[:-1]), competing=True)
146
+ terminal = terminals[0]
147
+ path = [terminal]
148
+ while path[-1] != start:
149
+ path.append(parent[path[-1]])
150
+ path.reverse()
151
+ return Forward(start, terminal, [terminal], path, seen - {terminal})
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Graph algorithms
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ def cycle_through(start: N, successors: Callable[[N], Iterable[N]]) -> list[N] | None:
160
+ """Return a cycle ``[start, ..., start]`` if ``start`` can reach itself, else None."""
161
+ parent: dict[N, N] = {}
162
+ queue: deque[N] = deque()
163
+ for nxt in successors(start):
164
+ if nxt not in parent:
165
+ parent[nxt] = start
166
+ queue.append(nxt)
167
+ while queue:
168
+ node = queue.popleft()
169
+ if node == start:
170
+ chain: list[N] = []
171
+ cur = parent[start]
172
+ while cur != start:
173
+ chain.append(cur)
174
+ cur = parent[cur]
175
+ return [start, *reversed(chain), start]
176
+ for nxt in successors(node):
177
+ if nxt not in parent:
178
+ parent[nxt] = node
179
+ queue.append(nxt)
180
+ return None
181
+
182
+
183
+ def strongly_connected_cycles(nodes: Iterable[N], successors: Callable[[N], Iterable[N]]) -> list[list[N]]:
184
+ """Tarjan's algorithm (iterative); returns SCCs that contain a cycle."""
185
+ index_of: dict[N, int] = {}
186
+ low: dict[N, int] = {}
187
+ on_stack: set[N] = set()
188
+ stack: list[N] = []
189
+ result: list[list[N]] = []
190
+ counter = 0
191
+ for root in nodes:
192
+ if root in index_of:
193
+ continue
194
+ work: list[tuple[N, Iterable[N]]] = [(root, iter(list(successors(root))))]
195
+ index_of[root] = low[root] = counter
196
+ counter += 1
197
+ stack.append(root)
198
+ on_stack.add(root)
199
+ while work:
200
+ node, it = work[-1]
201
+ advanced = False
202
+ for nxt in it:
203
+ if nxt not in index_of:
204
+ index_of[nxt] = low[nxt] = counter
205
+ counter += 1
206
+ stack.append(nxt)
207
+ on_stack.add(nxt)
208
+ work.append((nxt, iter(list(successors(nxt)))))
209
+ advanced = True
210
+ break
211
+ if nxt in on_stack:
212
+ low[node] = min(low[node], index_of[nxt])
213
+ if advanced:
214
+ continue
215
+ work.pop()
216
+ if work:
217
+ low[work[-1][0]] = min(low[work[-1][0]], low[node])
218
+ if low[node] == index_of[node]:
219
+ comp: list[N] = []
220
+ while True:
221
+ member = stack.pop()
222
+ on_stack.discard(member)
223
+ comp.append(member)
224
+ if member == node:
225
+ break
226
+ if len(comp) > 1 or node in set(successors(node)):
227
+ result.append(sorted(comp, key=str))
228
+ return result
@@ -0,0 +1,19 @@
1
+ """Shared Unicode normalization for card text and exact-match keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+
8
+ # Default-ignorable characters that must not change what text matches.
9
+ _IGNORABLE = re.compile("[­​-‍⁠︀-️\U000e0100-\U000e01ef]")
10
+
11
+
12
+ def normalize(text: str) -> str:
13
+ """Search-side normalization: drop ignorable characters, then NFKC."""
14
+ return unicodedata.normalize("NFKC", _IGNORABLE.sub("", text or ""))
15
+
16
+
17
+ def fold(text: str) -> str:
18
+ """Normalization for exact-match keys (aliases, tags): NFKC + case folding + whitespace."""
19
+ return " ".join(normalize(text).casefold().split())
@@ -0,0 +1,129 @@
1
+ """Minimal, verified TOML v1.0 serializer for MindSlice's flat schemas.
2
+
3
+ ``tomllib`` only parses, so MindSlice ships its own writer for the two shapes
4
+ it emits: card frontmatter (``str`` / ``list[str]`` values) and manifests such
5
+ as ``mounts.toml`` / ``slice.toml`` (which additionally use ``int`` / ``bool``
6
+ values and tables).
7
+
8
+ Strings are emitted as UTF-8 directly (never as UTF-16 surrogate escapes the
9
+ way ``json.dumps`` would), with the TOML short escapes for ``\\ \" \\b \\t \\n
10
+ \\f \\r`` and 4-digit ``\\uXXXX`` scalars for the remaining control characters
11
+ (U+0000-U+001F, U+007F). Every document produced here is re-parsed with
12
+ ``tomllib.loads()`` and compared against its input before it is returned.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import tomllib
19
+ from typing import Any, Mapping
20
+
21
+ from ..errors import FrontmatterError
22
+
23
+ _BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
24
+ _SHORT_ESCAPES = {
25
+ "\\": "\\\\",
26
+ '"': '\\"',
27
+ "\b": "\\b",
28
+ "\t": "\\t",
29
+ "\n": "\\n",
30
+ "\f": "\\f",
31
+ "\r": "\\r",
32
+ }
33
+
34
+
35
+ def escape_string(value: str) -> str:
36
+ """Return ``value`` as a TOML basic string literal (including quotes)."""
37
+ out = ['"']
38
+ for ch in value:
39
+ code = ord(ch)
40
+ if ch in _SHORT_ESCAPES:
41
+ out.append(_SHORT_ESCAPES[ch])
42
+ elif code < 0x20 or code == 0x7F:
43
+ out.append(f"\\u{code:04X}")
44
+ elif 0xD800 <= code <= 0xDFFF:
45
+ raise FrontmatterError(f"lone UTF-16 surrogate U+{code:04X} cannot be encoded in TOML")
46
+ else:
47
+ out.append(ch)
48
+ out.append('"')
49
+ return "".join(out)
50
+
51
+
52
+ def format_key(key: str) -> str:
53
+ if not isinstance(key, str) or not key:
54
+ raise FrontmatterError(f"invalid TOML key: {key!r}")
55
+ return key if _BARE_KEY.match(key) else escape_string(key)
56
+
57
+
58
+ def format_value(value: Any) -> str:
59
+ if isinstance(value, bool):
60
+ return "true" if value else "false"
61
+ if isinstance(value, int):
62
+ return str(value)
63
+ if isinstance(value, str):
64
+ return escape_string(value)
65
+ if isinstance(value, (list, tuple)):
66
+ return "[" + ", ".join(format_value(v) for v in value) + "]"
67
+ raise FrontmatterError(f"unsupported TOML value type: {type(value).__name__}")
68
+
69
+
70
+ def _verify(text: str, expected: Mapping[str, Any]) -> str:
71
+ try:
72
+ parsed = tomllib.loads(text)
73
+ except tomllib.TOMLDecodeError as exc: # pragma: no cover - defensive
74
+ raise FrontmatterError(f"serializer produced invalid TOML: {exc}") from exc
75
+ if parsed != _normalize(expected):
76
+ raise FrontmatterError("TOML round-trip verification failed")
77
+ return text
78
+
79
+
80
+ def _normalize(obj: Any) -> Any:
81
+ if isinstance(obj, Mapping):
82
+ return {k: _normalize(v) for k, v in obj.items()}
83
+ if isinstance(obj, tuple):
84
+ return [_normalize(v) for v in obj]
85
+ if isinstance(obj, list):
86
+ return [_normalize(v) for v in obj]
87
+ return obj
88
+
89
+
90
+ def dumps_flat(data: Mapping[str, Any]) -> str:
91
+ """Serialize a flat mapping of scalars / lists of scalars (card frontmatter)."""
92
+ lines = []
93
+ for key, value in data.items():
94
+ if isinstance(value, Mapping):
95
+ raise FrontmatterError(f"frontmatter key {key!r} must be flat (got a table)")
96
+ lines.append(f"{format_key(key)} = {format_value(value)}")
97
+ text = "\n".join(lines) + ("\n" if lines else "")
98
+ return _verify(text, data)
99
+
100
+
101
+ def dumps_document(
102
+ top: Mapping[str, Any] | None = None,
103
+ tables: Mapping[str, Mapping[str, Any]] | None = None,
104
+ array_tables: Mapping[str, list[Mapping[str, Any]]] | None = None,
105
+ header: str | None = None,
106
+ ) -> str:
107
+ """Serialize a document made of top-level keys, ``[tables]`` and ``[[array tables]]``."""
108
+ top = dict(top or {})
109
+ tables = dict(tables or {})
110
+ array_tables = dict(array_tables or {})
111
+ chunks: list[str] = []
112
+ if header:
113
+ chunks.append("".join(f"# {line}\n" if line else "#\n" for line in header.splitlines()))
114
+ if top:
115
+ chunks.append("".join(f"{format_key(k)} = {format_value(v)}\n" for k, v in top.items()))
116
+ for name, body in tables.items():
117
+ lines = [f"[{format_key(name)}]"]
118
+ lines += [f"{format_key(k)} = {format_value(v)}" for k, v in body.items()]
119
+ chunks.append("\n".join(lines) + "\n")
120
+ for name, items in array_tables.items():
121
+ for body in items:
122
+ lines = [f"[[{format_key(name)}]]"]
123
+ lines += [f"{format_key(k)} = {format_value(v)}" for k, v in body.items()]
124
+ chunks.append("\n".join(lines) + "\n")
125
+ text = "\n".join(chunks)
126
+ expected: dict[str, Any] = dict(top)
127
+ expected.update({k: dict(v) for k, v in tables.items()})
128
+ expected.update({k: [dict(i) for i in v] for k, v in array_tables.items() if v})
129
+ return _verify(text, expected)
@@ -0,0 +1 @@
1
+ """Workspace memory operations, staging, startup context and validation."""
@@ -0,0 +1,195 @@
1
+ """Human-facing history and read explanations, without guessing unrecorded reasons."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from ..errors import AmbiguousAddressError, NotFoundError
7
+ from ..model.addressing import format_address, parse_address
8
+ from ..retrieval import query
9
+
10
+
11
+ def history_path(ws, path, records=None):
12
+ """Translate old absolute version pointers after a portable workspace restore."""
13
+ raw = str(path).replace('\\', '/').rstrip('/')
14
+ for record in reversed(ws.history.events() if records is None else records):
15
+ if record['event'] != 'restored':
16
+ continue
17
+ for source, relative in sorted(record.get('path_map', {}).items(), key=lambda x: -len(x[0])):
18
+ source = source.replace('\\', '/').rstrip('/')
19
+ if raw == source or raw.startswith(source + '/'):
20
+ return ws.root / relative / raw[len(source):].lstrip('/')
21
+ return Path(path)
22
+
23
+
24
+ def _baked_revision(ws, root, path, sha, records):
25
+ """Follow a bake's parent for unchanged cards; a recorded mismatch ends attribution."""
26
+ versions = {str(history_path(ws, e['path'], records)): e for e in records if e['event'] == 'baked'}
27
+ seen = set()
28
+ current = str(root)
29
+ while current not in seen and current in versions:
30
+ seen.add(current)
31
+ e = versions[current]
32
+ card = next((c for c in e.get('cards', []) if c['target'].partition('/')[2] == path), None)
33
+ if card:
34
+ return (True, card['change_ids']) if card['after_sha256'] == sha else (False, [])
35
+ if not e.get('previous_path'):
36
+ break
37
+ current = str(history_path(ws, e['previous_path'], records))
38
+ return False, []
39
+
40
+
41
+ def _event_addresses(record):
42
+ addresses = {record['address']} if record.get('address') else set()
43
+ addresses.update(record.get('retired_overlay_cards', []))
44
+ for card in record.get('cards', []):
45
+ addresses.update(card[k] for k in ('source', 'target') if card.get(k))
46
+ return addresses
47
+
48
+
49
+ def card_history(ws, address=None, limit=20, offset=0):
50
+ query._nonneg(limit=limit, offset=offset)
51
+ with ws._reading() as table:
52
+ records = ws.history.events()
53
+ canonical = None
54
+ physical_versions = set()
55
+ if address is not None:
56
+ alias, path = parse_address(address)
57
+ try:
58
+ canonical, _ = query._select(query._Ctx(ws, table, False), address)
59
+ selected_alias, _ = parse_address(canonical)
60
+ selected_mount = table.get(selected_alias)
61
+ if selected_mount.kind == 'slice':
62
+ version = str(selected_mount.root)
63
+ versions = {str(history_path(ws, e['path'], records)): e for e in records if e['event'] == 'baked'}
64
+ while version not in physical_versions:
65
+ physical_versions.add(version)
66
+ previous = versions.get(version, {}).get('previous_path')
67
+ if not previous:
68
+ break
69
+ version = str(history_path(ws, previous, records))
70
+ except NotFoundError:
71
+ if alias:
72
+ canonical = format_address(alias, path)
73
+ else:
74
+ matches = sorted({a for e in records for a in _event_addresses(e) if a.partition('/')[2] == path})
75
+ if len(matches) > 1:
76
+ raise AmbiguousAddressError('historical address is ambiguous; use @alias/path', candidates=matches)
77
+ canonical = matches[0] if matches else format_address(table.overlay.alias, path)
78
+ if canonical:
79
+ def matches(card, record):
80
+ return canonical in (card.get('source'), card.get('target')) or (
81
+ record.get('path') and str(history_path(ws, record['path'], records)) in physical_versions
82
+ and card.get('target', '').partition('/')[2] == canonical.partition('/')[2])
83
+ ids = {e['change_id'] for e in records if e.get('address') == canonical and e.get('change_id')}
84
+ for e in records:
85
+ for card in e.get('cards', []):
86
+ if matches(card, e):
87
+ ids.update(card.get('change_ids', []))
88
+ filtered = []
89
+ for e in records:
90
+ cards = [c for c in e.get('cards', []) if matches(c, e)
91
+ or ids.intersection(c.get('change_ids', []))]
92
+ pointer_change = e['event'] == 'rolled_back' and e.get('alias') == canonical.partition('/')[0]
93
+ retired = canonical in e.get('retired_overlay_cards', [])
94
+ if e.get('address') == canonical or e.get('change_id') in ids or cards or pointer_change or retired:
95
+ entry = dict(e)
96
+ if 'retired_overlay_cards' in entry:
97
+ entry['retired_overlay_cards'] = [a for a in e['retired_overlay_cards'] if a == canonical
98
+ or any(c.get('source') == a for c in cards)]
99
+ if 'cards' in e:
100
+ entry['cards'] = cards
101
+ entry['change_ids'] = list(dict.fromkeys(i for c in cards for i in c.get('change_ids', [])))
102
+ filtered.append(entry)
103
+ records = filtered
104
+ details = ws.history.details()
105
+ records = list(reversed(records))
106
+ page = []
107
+ for e in records[offset:offset + limit]:
108
+ entry = dict(e)
109
+ if 'reason' not in entry and entry.get('change_id') in details:
110
+ entry['reason'] = details[entry['change_id']].get('reason')
111
+ if entry.get('change_ids'):
112
+ entry['changes'] = ws.history.descriptions(entry['change_ids'], records=details)
113
+ page.append(entry)
114
+ return {'address': canonical, 'events': page, 'total': len(records), 'offset': offset,
115
+ 'next_offset': query._page(len(records), offset, limit),
116
+ 'content_snapshots': False,
117
+ 'note': 'Metadata history only. External editor changes are not automatically logged; old text requires a slice version or backup.'}
118
+
119
+
120
+ def explain(ws, address, follow_supersedes=True, max_extensions=5, extensions_offset=0):
121
+ query._nonneg(max_extensions=max_extensions, extensions_offset=extensions_offset)
122
+ with ws._reading() as table:
123
+ ctx = query._Ctx(ws, table, follow_supersedes)
124
+ selected, shadows = query._select(ctx, address)
125
+ result = query.resolve(ctx, address, max_backlinks=0, max_extensions=max_extensions,
126
+ extensions_offset=extensions_offset, bump_telemetry=False)
127
+ records = ws.history.events()
128
+ sources = []
129
+ parts = [('selected', ctx.card_payload(selected)), ('resolved', result)]
130
+ parts += [('baseline', c) for c in result.get('baselines', [])]
131
+ parts += [('extension', c) for c in result.get('extensions', [])]
132
+ for role, card in parts:
133
+ alias, path = parse_address(card['address'])
134
+ mount = table.get(alias)
135
+ changes = []
136
+ matched = False
137
+ if mount.kind == 'overlay':
138
+ changes = ws.history.changes_for(card['address'], card['sha256'])
139
+ matched = bool(changes)
140
+ else:
141
+ matched, ids = _baked_revision(ws, mount.root, path, card['sha256'], records)
142
+ changes = ws.history.descriptions(ids)
143
+ sources.append({'role': role, 'address': card['address'], 'path': str(mount.root / f'{path}.md'),
144
+ 'sha256': card['sha256'], 'mount_role': mount.role, 'precedence': mount.precedence,
145
+ 'revision_recorded': matched, 'changes': changes})
146
+ notices = {k: result[k] for k in ('status', 'competing_replacements', 'superseded_by_untrusted',
147
+ 'bypassed_replacements', 'unrebased_extensions', 'baseline_superseded_by', 'missing_baselines') if k in result}
148
+ return {'requested': address, 'selected': selected, 'resolved': result['address'],
149
+ 'follow_supersedes': follow_supersedes, 'lineage_path': result.get('lineage_path', [selected]),
150
+ 'shadows': shadows, 'sources': sources, 'notices': notices,
151
+ 'total_extensions': result.get('total_extensions', 0),
152
+ 'extensions_next_offset': result.get('extensions_next_offset'),
153
+ 'pending_review': [i['address'] for i in ws.diff()['items'] if i['address'] in {c['address'] for _, c in parts}],
154
+ 'note': 'Selection uses mount precedence, then explicit lineage under the supplied trust policy. '
155
+ 'Unrecorded revisions have no verified reason; pending edits do not affect this result.'}
156
+
157
+
158
+ def render_history(result):
159
+ lines = [f"History: {result['address'] or 'workspace'} ({result['total']} events; newest first)"]
160
+ for e in result['events']:
161
+ lines.append(f"{e['timestamp']} {e['event']} {e.get('address', e.get('alias', ''))}")
162
+ if e.get('reason'):
163
+ lines.append(' Reason: ' + e['reason'])
164
+ for c in e.get('changes', []):
165
+ if c.get('reason'):
166
+ lines.append(' Reason: ' + c['reason'])
167
+ for key in ('previous_path', 'path'):
168
+ if e.get(key):
169
+ lines.append(f" {key}: {e[key]}")
170
+ if result['next_offset'] is not None:
171
+ lines.append(f"More events: --offset {result['next_offset']}")
172
+ return '\n'.join(lines + [result['note']]) + '\n'
173
+
174
+
175
+ def render_explain(result):
176
+ lines = [f"Selected: {result['selected']}", f"Resolved: {result['resolved']}",
177
+ 'Lineage: ' + ' -> '.join(result['lineage_path'])]
178
+ if result['shadows']:
179
+ lines.append('Shadows: ' + ', '.join(result['shadows']))
180
+ for source in result['sources']:
181
+ if source['role'] == 'selected' and result['selected'] == result['resolved']:
182
+ continue
183
+ lines.append(f"{source['role']}: {source['address']} ({source['path']}; precedence={source['precedence']}, role={source['mount_role']})")
184
+ for change in source['changes']:
185
+ if change.get('reason'):
186
+ lines.append(' Reason: ' + change['reason'])
187
+ if not source['revision_recorded']:
188
+ lines.append(' No history record matching these current bytes.')
189
+ if result['pending_review']:
190
+ lines.append('Pending, not active: ' + ', '.join(result['pending_review']))
191
+ for name, value in result['notices'].items():
192
+ lines.append(f'{name}: {value}')
193
+ if result['extensions_next_offset'] is not None:
194
+ lines.append(f"More extensions: --extensions-offset {result['extensions_next_offset']}")
195
+ return '\n'.join(lines + [result['note']]) + '\n'