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,537 @@
1
+ """Workspace bake planning and execution. Review never holds a lock while waiting for a user.
2
+
3
+ A plan fingerprints its inputs; execution rebuilds and validates it under LOCK_EX. All outputs
4
+ are prepared before a single recoverable mount/overlay/history transaction is started.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ import json
10
+ import os
11
+ import shutil
12
+ import tempfile
13
+ from collections import defaultdict
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ from ..model.addressing import MountCatalog, format_address, normalize_alias, resolve_ref
18
+ from .bake import bump_version, default_output
19
+ from ..model.card import normalize_newlines, parse_card, sha256_bytes
20
+ from ..storage.config import MOUNTS_FILE, SLICE_FILE, Mount, MountTable, SliceManifest, dump_mounts, dump_slice_manifest, load_slice_manifest, portable_path
21
+ from ..errors import BakeConflictError, InvalidArgumentsError
22
+ from ..storage.fsutil import atomic_write_text, fsync_dir, fsync_file, iter_markdown
23
+ from ..storage.history import change, event, file_action, file_sha
24
+ from ..model.lineage import strongly_connected_cycles
25
+ from .slice_transfer import overlay_cards, rewrite_refs
26
+ from ..operations.staging import _diff_overlay, _diff_staging
27
+ from ..storage.card_io import read_card_file
28
+ from ..model.card import reassemble
29
+
30
+ if TYPE_CHECKING:
31
+ from ..workspace import Workspace
32
+
33
+ PLAN_FILE = ".bake-plan.json"
34
+ MERGE_FIX = ("merge the two into one card: rewrite the overlay card with the combined text and drop its "
35
+ "`extends` link")
36
+
37
+
38
+ def _stale(message: str, **details) -> BakeConflictError:
39
+ """A plan no longer matches the workspace: nothing to fix, only to plan again."""
40
+ return BakeConflictError(message, code="stale_bake_plan", next_steps=[
41
+ "Run `mindslice bake` again: the plan is rebuilt from the current workspace, and destination "
42
+ "choices saved for unchanged cards are kept."], **details)
43
+
44
+
45
+ def _selectors(plan: dict, ids: list[str]) -> list[str]:
46
+ """Shortest ``--assign``/``--defer`` selectors for ``ids``: a whole top folder when every card in it is listed."""
47
+ wanted, out = set(ids), []
48
+ for path in ids:
49
+ folder = path.partition("/")[0] + "/" if "/" in path else None
50
+ inside = [c["id"] for c in plan["cards"] if folder and c["id"].startswith(folder)]
51
+ selector = folder if folder and set(inside) <= wanted else path
52
+ if selector not in out:
53
+ out.append(selector)
54
+ return out
55
+
56
+
57
+ def blocked_message(plan: dict) -> str:
58
+ parts = []
59
+ if plan["unassigned"]:
60
+ parts.append(f"{len(plan['unassigned'])} overlay card(s) need a destination")
61
+ if plan["blockers"]:
62
+ parts.append(f"{len(plan['blockers'])} blocker(s) must be resolved")
63
+ return "bake stopped before writing: " + " and ".join(parts or ["the plan is not ready"])
64
+
65
+
66
+ def next_steps(plan: dict) -> list[str]:
67
+ """What the user has to do before this plan can bake; empty when it is ready."""
68
+ steps = []
69
+ if plan["unassigned"]:
70
+ selectors = _selectors(plan, plan["unassigned"])
71
+ shown = ", ".join(selectors[:5]) + (f" (+{len(selectors) - 5} more)" if len(selectors) > 5 else "")
72
+ assign = " ".join(f"--assign '{sel}=@ALIAS'" for sel in selectors[:3])
73
+ steps.append(f"Choose a destination for {shown}: `mindslice bake {assign}` sends cards to a new or mounted "
74
+ f"slice; use `--defer '{selectors[0]}'` instead to leave cards in overlay; "
75
+ "or run `mindslice bake` in a terminal to decide card by card.")
76
+ seen = set()
77
+ for item in plan["blockers"]:
78
+ if item.get("derived"): # disappears with the blocker it follows from
79
+ continue
80
+ where = item.get("address") or item.get("alias") or "workspace"
81
+ key = (where, item.get("ref")) if item.get("ref") else (where, item["reason"])
82
+ if key in seen: # one broken reference can surface as two blockers; its first fix covers both
83
+ continue
84
+ seen.add(key)
85
+ steps.append(f"{where}: {item.get('fix') or item['reason']}")
86
+ return steps
87
+
88
+
89
+ def _tree_files(root: Path):
90
+ """Match the baseline tree copied to a batch output, excluding workspace machinery."""
91
+ if not root.is_dir():
92
+ return
93
+ for current, dirs, files in os.walk(root, followlinks=True):
94
+ # Symlink loops are rejected rather than making a review hang.
95
+ for name in list(dirs):
96
+ p = Path(current) / name
97
+ if name in (".git", ".history", ".cache"):
98
+ dirs.remove(name)
99
+ elif p.is_symlink() and p.resolve() in {Path(current).resolve(), *(a.resolve() for a in Path(current).parents)}:
100
+ raise InvalidArgumentsError("directory symlink cycle in bake source", path=str(p))
101
+ for name in sorted(files):
102
+ if name == PLAN_FILE:
103
+ continue
104
+ yield Path(current) / name
105
+
106
+
107
+ def _revision(ws, table):
108
+ rows = [(str(ws.root / MOUNTS_FILE), file_sha(ws.root / MOUNTS_FILE))]
109
+ for mount in table.all():
110
+ rows.append((str(mount.root), mount.root.is_dir()))
111
+ for p in _tree_files(mount.root):
112
+ rows.append((str(p), file_sha(p)))
113
+ for p in _tree_files(ws.staging.root):
114
+ rows.append((str(p), file_sha(p)))
115
+ return sha256_bytes(json.dumps(sorted(rows), ensure_ascii=False).encode())
116
+
117
+
118
+ def _refs(parsed):
119
+ return [(k, r) for k in ("extends", "supersedes") for r in parsed.list_field(k)] + [
120
+ ("wikilink", r) for r in parsed.wikilink_targets()]
121
+
122
+
123
+ def load_plan(path: Path) -> dict:
124
+ try:
125
+ value = json.loads(path.read_text(encoding="utf-8"))
126
+ except (ValueError, OSError) as exc:
127
+ raise InvalidArgumentsError(f"cannot read bake plan: {path}: {exc}") from exc
128
+ valid = isinstance(value, dict) and value.get("schema_version") == 1
129
+ valid = valid and isinstance(value.get("assignments"), dict) and isinstance(value.get("cards"), list)
130
+ valid = valid and isinstance(value.get("slices"), list) and isinstance(value.get("workspace"), str)
131
+ valid = valid and isinstance(value.get("revision"), str)
132
+ if valid:
133
+ valid = all(isinstance(c, dict) and isinstance(c.get("id"), str) and isinstance(c.get("sha256"), str)
134
+ for c in value["cards"])
135
+ valid = valid and all(isinstance(p, str) and (a is None or isinstance(a, str))
136
+ for p, a in value["assignments"].items())
137
+ if not valid:
138
+ raise InvalidArgumentsError("invalid bake plan; regenerate with bake --dry-run --json", path=str(path))
139
+ return value
140
+
141
+
142
+ def save_plan(ws, plan):
143
+ with ws._writing() as table:
144
+ if plan.get("revision") != _revision(ws, table):
145
+ raise _stale("workspace changed during review; run bake again")
146
+ atomic_write_text(ws.root / PLAN_FILE, json.dumps(plan, ensure_ascii=False, indent=2) + "\n")
147
+
148
+
149
+ def plan_bake(ws: "Workspace", assignments: dict[str, str | None] | None = None, *, resume: bool = True) -> dict:
150
+ with ws._writing() as table:
151
+ ws._sync(table, verify=True)
152
+ choices = {}
153
+ saved = ws.root / PLAN_FILE
154
+ if resume and saved.exists():
155
+ old = load_plan(saved)
156
+ if old.get("workspace") != str(ws.root):
157
+ raise InvalidArgumentsError("saved bake plan belongs to another workspace")
158
+ old_hashes = {c["id"]: c["sha256"] for c in old.get("cards", [])}
159
+ live_paths = overlay_cards(ws, table)
160
+ for path, dest in old["assignments"].items():
161
+ # Choices survive baseline fixes, but changed/recreated cards need a new review.
162
+ if path in live_paths and path in old_hashes and old_hashes[path] == file_sha(table.overlay.root / f"{path}.md"):
163
+ choices[path] = dest
164
+ choices.update(assignments or {})
165
+ return _prepare(ws, table, choices)[0]
166
+
167
+
168
+ def _prepare(ws, table, assignments):
169
+ revision = _revision(ws, table)
170
+ cards = overlay_cards(ws, table)
171
+ if not isinstance(assignments, dict) or any(not isinstance(p, str) or p not in cards for p in assignments):
172
+ raise InvalidArgumentsError("bake assignments must use existing overlay card IDs")
173
+ choices = {}
174
+ for path, alias in assignments.items():
175
+ if alias is not None and not isinstance(alias, str):
176
+ raise InvalidArgumentsError("a bake destination must be an alias or null (defer)", id=path)
177
+ dest = normalize_alias(alias) if alias is not None else None
178
+ if dest == table.overlay.alias:
179
+ raise InvalidArgumentsError("overlay is not a bake destination", id=path)
180
+ choices[path] = dest
181
+ cats = ws.index.catalogs()
182
+ pending = _diff_staging(ws, table)["items"]
183
+ staged = {i["id"] for i in pending}
184
+ diff = {i["id"]: i for i in _diff_overlay(ws, table)["items"]}
185
+ items, blockers = [], []
186
+ for path, (text, parsed) in sorted(cards.items()):
187
+ candidates = set()
188
+ for m in table.mounts:
189
+ if path in cats[m.alias].paths:
190
+ candidates.add(m.alias)
191
+ for raw in parsed.extends + parsed.supersedes:
192
+ ref = resolve_ref(raw, table.overlay.alias, cats)
193
+ if ref.resolved and ref.mount != table.overlay.alias:
194
+ candidates.add(ref.mount)
195
+ if path not in choices:
196
+ if path in staged:
197
+ choices[path] = None
198
+ elif len(candidates) == 1:
199
+ choices[path] = next(iter(candidates))
200
+ item = dict(diff.get(path, {}), id=path, address=format_address(table.overlay.alias, path),
201
+ sha256=file_sha(table.overlay.root / f"{path}.md"), summary=parsed.index_parts()[0],
202
+ candidates=sorted(candidates), destination=choices.get(path),
203
+ state="unassigned" if path not in choices else ("assigned" if choices[path] else "deferred"))
204
+ items.append(item)
205
+ if choices.get(path) and path in staged:
206
+ blockers.append({"address": item["address"], "reason": "staged change pending; apply, discard or defer this card",
207
+ "fix": f"`mindslice apply {path}` to accept the staged change or `mindslice discard {path}` "
208
+ f"to drop it, or keep the card in overlay with `--defer {path}`"})
209
+ destinations = {p: a for p, a in choices.items() if a}
210
+ groups = defaultdict(list)
211
+ for path, alias in sorted(destinations.items()):
212
+ groups[alias].append(path)
213
+
214
+ # Original resolution for overlay references; future catalogs also permit explicit references
215
+ # to a new slice whose cards are assigned by this very plan.
216
+ future = copy.deepcopy(cats)
217
+ for path, alias in destinations.items():
218
+ future.setdefault(alias, MountCatalog()).add(path, cards[path][1].aliases)
219
+
220
+ def resolve(raw, source):
221
+ result = resolve_ref(raw, source, cats)
222
+ return result if result.resolved else resolve_ref(raw, source, future)
223
+
224
+ def rewrite(text, parsed, source, target, path, *, portable=True):
225
+ def mapper(raw):
226
+ ref = resolve(raw, source)
227
+ if not ref.resolved:
228
+ if portable:
229
+ blockers.append({"address": format_address(target, path), "ref": raw, "reason": "unresolved reference",
230
+ "fix": f"the card references {raw!r}, which no mounted card matches: correct or "
231
+ "remove that reference"
232
+ + (f", or keep the card in overlay with `--defer {path}`"
233
+ if source == table.overlay.alias else "")})
234
+ return raw
235
+ alias, dest_path = ref.mount, ref.path
236
+ if alias == table.overlay.alias:
237
+ alias = destinations.get(dest_path)
238
+ if alias is None:
239
+ if portable:
240
+ needed = format_address(table.overlay.alias, dest_path)
241
+ blockers.append({"address": format_address(target, path), "ref": raw,
242
+ "reason": "referenced overlay card needs a destination",
243
+ "fix": f"it references {needed}, which stays in overlay: bake that card too "
244
+ f"with `--assign '{dest_path}={target}'`"
245
+ + (f", or keep this card in overlay with `--defer {path}`"
246
+ if source == table.overlay.alias else "")})
247
+ return raw
248
+ if not portable and ref.mount != table.overlay.alias:
249
+ return raw
250
+ return dest_path if alias == target else format_address(alias, dest_path)
251
+ result = rewrite_refs(text, parsed, mapper)
252
+ return normalize_newlines(result)
253
+
254
+ # Keep overlay copies required by pending work or unchanged read-only mounts. Otherwise all
255
+ # retiring targets are mapped together, so a deferred referrer is rewritten only once.
256
+ retained = {}
257
+ def protect(parsed, source, cause):
258
+ for _, raw in _refs(parsed):
259
+ ref = resolve(raw, source)
260
+ if ref.resolved and ref.mount == table.overlay.alias and ref.path in destinations:
261
+ retained.setdefault(ref.path, cause)
262
+ for item in pending:
263
+ path = item["id"]
264
+ if path in cards:
265
+ protect(cards[path][1], table.overlay.alias, "referenced by pending overlay card")
266
+ text, _ = read_card_file(ws.staging.file(path))
267
+ if text is not None:
268
+ protect(parse_card(text), table.overlay.alias, "referenced by staged content")
269
+ for m in table.mounts:
270
+ if m.alias not in groups:
271
+ for path, _ in iter_markdown(m.root, exclude=tuple(ws._exclude())):
272
+ text, _ = read_card_file(m.root / f"{path}.md")
273
+ if text is not None:
274
+ protect(parse_card(text), m.alias, f"referenced by read-only {m.alias}")
275
+ last = -1
276
+ while last != len(retained):
277
+ last = len(retained)
278
+ for path in list(retained):
279
+ protect(cards[path][1], table.overlay.alias, "required by retained overlay copy")
280
+
281
+ prepared, slices, used_outputs = {}, [], set()
282
+ projected = {}
283
+ same_path = {} # baked address -> the overlay card that extends it at its own path
284
+ for alias, paths in sorted(groups.items()):
285
+ mount = table.get(alias)
286
+ files = {}
287
+ baseline_rebased = []
288
+ if mount:
289
+ if not mount.root.is_dir():
290
+ blockers.append({"alias": alias, "reason": "mounted directory is missing",
291
+ "fix": f"restore {mount.root}, or `mindslice unmount {alias}` and assign its cards "
292
+ "elsewhere (`mindslice doctor` lists missing directories)"})
293
+ for path, _ in iter_markdown(mount.root, exclude=tuple(ws._exclude())):
294
+ text, _ = read_card_file(mount.root / f"{path}.md")
295
+ if text is not None and path not in paths:
296
+ files[path] = rewrite(text, parse_card(text), alias, alias, path)
297
+ if files[path] != normalize_newlines(text):
298
+ baseline_rebased.append(format_address(alias, path))
299
+ for path in paths:
300
+ text, parsed = cards[path]
301
+ for raw in parsed.extends:
302
+ ref = resolve(raw, table.overlay.alias)
303
+ if ref.resolved and ref.mount == alias and ref.path == path:
304
+ same_path[format_address(alias, path)] = format_address(table.overlay.alias, path)
305
+ blockers.append({"address": format_address(table.overlay.alias, path),
306
+ "reason": "extends destination at the same path; consolidate content first",
307
+ "fix": f"{MERGE_FIX}, or keep the card in overlay with `--defer {path}`"})
308
+ value = rewrite(text, parsed, table.overlay.alias, alias, path)
309
+ parsed_new = parse_card(value)
310
+ if path in parsed_new.supersedes:
311
+ fields = dict(parsed_new.frontmatter, supersedes=[r for r in parsed_new.supersedes if r != path])
312
+ value = reassemble(parsed_new, value, fields, None)
313
+ files[path] = value
314
+ sm = load_slice_manifest(mount.root) if mount else SliceManifest(
315
+ name=alias[1:], version="0.1.0", alias=alias, description=f"Baked from {table.overlay.alias}", present=True)
316
+ if sm.errors:
317
+ blockers.append({"alias": alias, "reason": "invalid slice manifest", "errors": sm.errors,
318
+ "fix": f"correct {mount.root / SLICE_FILE} (`mindslice validate {mount.root}` lists the problems)"})
319
+ sm.alias = alias
320
+ sm.name = sm.name or (mount.root.name.removesuffix(".slice") if mount else alias[1:])
321
+ sm.version = bump_version(sm.version) if mount and sm.version else (sm.version or "0.1.0")
322
+ declared = set()
323
+ for dep in sm.dependencies:
324
+ try:
325
+ declared.add(normalize_alias(dep))
326
+ except ValueError:
327
+ blockers.append({"alias": alias, "dependency": dep, "reason": "invalid dependency alias",
328
+ "fix": f"correct the dependency {dep!r} in {mount.root / SLICE_FILE if mount else SLICE_FILE}"})
329
+ dependencies = set(declared)
330
+ for path, value in files.items():
331
+ parsed = parse_card(value)
332
+ projected[(alias, path)] = parsed
333
+ if parsed.errors:
334
+ blockers.append({"address": format_address(alias, path), "reason": "invalid card", "errors": parsed.errors,
335
+ "fix": "correct the card's frontmatter so it parses"
336
+ + (f", or keep it in overlay with `--defer {path}`" if path in paths else "")})
337
+ for _, raw in _refs(parsed):
338
+ ref = resolve_ref(raw, alias, future)
339
+ if ref.mount and ref.mount != alias:
340
+ dependencies.add(ref.mount)
341
+ dependencies.discard(alias)
342
+ sm.dependencies = sorted(dependencies)
343
+ for dep in sm.dependencies:
344
+ if dep not in future or dep == table.overlay.alias:
345
+ blockers.append({"alias": alias, "dependency": dep, "reason": "missing or nonportable dependency",
346
+ "fix": (f"cards bound for {alias} reference overlay cards that stay behind: give those "
347
+ "cards a destination too" if dep == table.overlay.alias else
348
+ f"mount the slice it depends on (`mindslice mount PATH --alias {dep}`), or "
349
+ f"drop {dep} from the slice's declared dependencies and card references")})
350
+ output = default_output(ws, alias, mount)
351
+ # Two mounts can have the same directory basename; reserve outputs across the full batch.
352
+ while str(output) in used_outputs:
353
+ output = output.with_name(output.stem + "-2.slice")
354
+ used_outputs.add(str(output))
355
+ for root in [ws.staging.root, ws.cache_dir, ws.history.root] + [m.root for m in table.all()]:
356
+ if output.resolve() == root.resolve() or root.resolve() in output.resolve().parents:
357
+ blockers.append({"alias": alias, "reason": "output falls inside a mounted/internal directory", "path": str(output),
358
+ "fix": f"bake this slice on its own to another location: `mindslice bake {alias} --output DIR`"
359
+ + ("" if mount else " (with `--prefix` to select its cards)")})
360
+ slices.append({"alias": alias, "path": str(output), "output": str(output),
361
+ "previous_path": str(mount.root) if mount else None, "version": sm.version,
362
+ "action": "update" if mount else "create", "cards": paths,
363
+ "rebased_baseline_cards": sorted(baseline_rebased),
364
+ "dependencies": sm.dependencies, "dependencies_added": sorted(dependencies - declared)})
365
+ prepared[alias] = (files, sm, output, mount)
366
+
367
+ # Validate the full future lineage, including unchanged mounts. Detect cycles introduced by
368
+ # assigning different overlay cards to the same baseline namespace.
369
+ graph = defaultdict(list)
370
+ for row in ws.index.all_cards():
371
+ key = (row["mount"], row["path"])
372
+ if row["mount"] in groups or (row["mount"] == table.overlay.alias and row["path"] in destinations and row["path"] not in retained):
373
+ continue
374
+ mount = table.get(row["mount"])
375
+ text, _ = read_card_file(mount.root / f"{row['path']}.md")
376
+ if text is not None:
377
+ projected[key] = parse_card(text)
378
+ for (alias, path), parsed in projected.items():
379
+ for kind, raw in _refs(parsed):
380
+ ref = resolve_ref(raw, alias, future)
381
+ if alias in groups and not ref.resolved:
382
+ blockers.append({"address": format_address(alias, path), "ref": raw, "reason": "unresolved output reference",
383
+ "fix": f"the baked card would reference {raw!r}, which nothing in the planned workspace "
384
+ "matches: correct or remove that reference, or bake its target in the same run"})
385
+ if kind != "wikilink" and ref.resolved:
386
+ graph[format_address(alias, path)].append(ref.address)
387
+ nodes = set(graph) | {n for values in graph.values() for n in values}
388
+ for cycle in strongly_connected_cycles(sorted(nodes), lambda n: graph.get(n, ())):
389
+ cause = same_path.get(cycle[0]) if len(cycle) == 1 else None
390
+ blockers.append({"reason": "lineage cycle in planned workspace", "cards": cycle} | (
391
+ # Baked as it is, a card extending its own path would extend itself: one problem, not two.
392
+ {"fix": f"follows from {cause} extending this path; resolving that card removes the cycle", "derived": True}
393
+ if cause else
394
+ {"fix": "remove one `extends`/`supersedes` link among " + ", ".join(cycle) + " to break the cycle"}))
395
+
396
+ # Retained and staged cards stay byte-identical. All other remaining overlay refs are rebased
397
+ # in one pass, using only targets that really retire.
398
+ retired = set(destinations) - set(retained)
399
+ rebased = {}
400
+ for path, (text, parsed) in cards.items():
401
+ if path in destinations or path in staged:
402
+ continue
403
+ def mapper(raw):
404
+ ref = resolve(raw, table.overlay.alias)
405
+ if ref.resolved and ref.mount == table.overlay.alias and ref.path in retired:
406
+ return format_address(destinations[ref.path], ref.path)
407
+ return raw
408
+ value = rewrite_refs(text, parsed, mapper)
409
+ if value != text:
410
+ rebased[path] = value
411
+ plan = {"schema_version": 1, "workspace": str(ws.root), "revision": revision,
412
+ "assignments": choices, "cards": items, "slices": slices,
413
+ "unassigned": [i["id"] for i in items if i["state"] == "unassigned"],
414
+ "deferred": [p for p, a in choices.items() if a is None],
415
+ "pending_review": pending, "retained_overlay_cards": [
416
+ {"address": format_address(table.overlay.alias, p), "reason": retained[p]} for p in sorted(retained)],
417
+ "rebased_references": sorted(rebased), "blockers": blockers,
418
+ "remaining_overlay_cards": sorted(set(cards) - retired)}
419
+ plan["ready"] = not (plan["unassigned"] or blockers)
420
+ if revision != _revision(ws, table):
421
+ raise _stale("files changed during planning; retry review")
422
+ return plan, prepared, retired, rebased
423
+
424
+
425
+ def _reviewed(ws, table, reviewed):
426
+ if not isinstance(reviewed, dict) or reviewed.get("schema_version") != 1 or reviewed.get("workspace") != str(ws.root):
427
+ raise InvalidArgumentsError("invalid bake plan or wrong workspace")
428
+ if reviewed.get("revision") != _revision(ws, table):
429
+ raise _stale("workspace changed since review; run bake again")
430
+ ws._sync(table, verify=True)
431
+ prepared = _prepare(ws, table, reviewed.get("assignments"))
432
+ if prepared[0]["slices"] != reviewed.get("slices"):
433
+ raise _stale("bake outputs changed since review; run bake again")
434
+ return prepared
435
+
436
+
437
+ def inspect_plan(ws: "Workspace", reviewed: dict) -> dict:
438
+ with ws._writing() as table:
439
+ return _reviewed(ws, table, reviewed)[0]
440
+
441
+
442
+ def execute(ws: "Workspace", reviewed: dict) -> dict:
443
+ with ws._writing() as table:
444
+ plan, prepared, retired, rebased = _reviewed(ws, table, reviewed)
445
+ if not plan["ready"]:
446
+ raise BakeConflictError(blocked_message(plan), plan=plan, next_steps=next_steps(plan) + [
447
+ "Then plan and bake again."])
448
+ if not prepared:
449
+ return {"baked": [], "remaining_overlay_cards": plan["remaining_overlay_cards"],
450
+ "retained_overlay_cards": [], "pending_review": len(plan["pending_review"]),
451
+ "rebased_references": [], "dry_run": False}
452
+ ws.history.events() # fail before producing outputs if the log is corrupt
453
+ temporary = Path(tempfile.mkdtemp(prefix="mindslice-batch-"))
454
+ published = []
455
+ transaction_started = False
456
+ try:
457
+ # Preparation is isolated. No mount, overlay or final output changes until all trees exist.
458
+ for alias, (files, sm, output, mount) in prepared.items():
459
+ tree = temporary / alias[1:]
460
+ tree.mkdir()
461
+ if mount:
462
+ for source in _tree_files(mount.root):
463
+ dest = tree / source.relative_to(mount.root)
464
+ dest.parent.mkdir(parents=True, exist_ok=True)
465
+ shutil.copyfile(source, dest)
466
+ for path, text in files.items():
467
+ atomic_write_text(tree / f"{path}.md", text)
468
+ atomic_write_text(tree / SLICE_FILE, dump_slice_manifest(sm))
469
+ if plan["revision"] != _revision(ws, table):
470
+ raise _stale("files changed while preparing bake; review again")
471
+ # Publish via a sibling temp directory so the final rename works across devices.
472
+ for alias, (_, _, output, _) in prepared.items():
473
+ if output.exists():
474
+ raise BakeConflictError("output appeared during bake", path=str(output), next_steps=[
475
+ "Run `mindslice bake` again: planning picks an output directory that is still free."])
476
+ output.parent.mkdir(parents=True, exist_ok=True)
477
+ sibling = Path(tempfile.mkdtemp(prefix=".bake-", dir=output.parent))
478
+ try:
479
+ shutil.copytree(temporary / alias[1:], sibling / "tree")
480
+ for p in _tree_files(sibling / "tree"):
481
+ fsync_file(p)
482
+ for directory, _, _ in os.walk(sibling / "tree", topdown=False):
483
+ fsync_dir(Path(directory))
484
+ os.rename(sibling / "tree", output)
485
+ published.append(output)
486
+ fsync_dir(output.parent)
487
+ finally:
488
+ shutil.rmtree(sibling, ignore_errors=True)
489
+ new_mounts = {a: Mount(a, portable_path(ws.root, output), m.precedence if m else 50,
490
+ m.role if m else "baseline", "slice", output)
491
+ for a, (_, _, output, m) in prepared.items()}
492
+ mounts = [new_mounts.pop(m.alias, m) for m in table.mounts] + list(new_mounts.values())
493
+ actions = [file_action(ws.root / MOUNTS_FILE, dump_mounts(MountTable(table.overlay, mounts)))]
494
+ events = []
495
+ for path, text in rebased.items():
496
+ action = file_action(table.overlay.root / f"{path}.md", text)
497
+ actions.append(action)
498
+ events.append(change("applied", address=format_address(table.overlay.alias, path), op="rebase", origin="engine",
499
+ reason="Rebase references after workspace bake", before_sha256=action["before_sha256"],
500
+ after_sha256=action["after_sha256"]))
501
+ actions += [file_action(table.overlay.root / f"{p}.md", None, table.overlay.root) for p in sorted(retired)]
502
+ if (ws.root / PLAN_FILE).exists():
503
+ actions.append(file_action(ws.root / PLAN_FILE, None))
504
+ active = ws.history.applied()
505
+ receipt = []
506
+ for result in plan["slices"]:
507
+ baked_cards = []
508
+ for path in result["cards"]:
509
+ address = format_address(table.overlay.alias, path)
510
+ before = file_sha(table.overlay.root / f"{path}.md")
511
+ tip, ids = active.get(address, (None, []))
512
+ baked_cards.append({"source": address, "target": format_address(result["alias"], path),
513
+ "before_sha256": before, "after_sha256": file_sha(Path(result["path"]) / f"{path}.md"),
514
+ "change_ids": ids if tip == before else []})
515
+ record = event("baked", alias=result["alias"], path=result["path"], output=result["output"],
516
+ previous_path=result["previous_path"], version=result["version"], cards=baked_cards,
517
+ change_ids=list(dict.fromkeys(id for c in baked_cards for id in c["change_ids"])),
518
+ retired_overlay_cards=[format_address(table.overlay.alias, p) for p in result["cards"] if p in retired])
519
+ events.append(record)
520
+ receipt.append(dict(result, history_event_id=record["event_id"]))
521
+ guards = [{"path": str(p), "sha256": file_sha(p)} for output in published for p in _tree_files(output)]
522
+ if plan["revision"] != _revision(ws, table):
523
+ raise _stale("files changed before commit; review again")
524
+ transaction_started = True
525
+ ws.history.transact(actions, events, guards=guards)
526
+ ws._sync(ws._reload_table())
527
+ return {"baked": receipt, "remaining_overlay_cards": plan["remaining_overlay_cards"],
528
+ "retained_overlay_cards": plan["retained_overlay_cards"], "pending_review": len(plan["pending_review"]),
529
+ "rebased_references": plan["rebased_references"], "dry_run": False}
530
+ except BaseException:
531
+ # Once journaled, recovery owns the outputs. Never remove files a pending commit needs.
532
+ if not transaction_started:
533
+ for output in published:
534
+ shutil.rmtree(output)
535
+ raise
536
+ finally:
537
+ shutil.rmtree(temporary, ignore_errors=True)