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,265 @@
1
+ """Reference rewriting, dependency closure and overlay retirement shared by bake and mount.
2
+
3
+ Every reference (``extends``, ``supersedes``, ``[[wikilinks]]``) is first
4
+ resolved against ``@overlay`` (source-first), then classified:
5
+
6
+ * included ``@overlay`` target -> written slice-relative (``<path>``)
7
+ * external slice target -> kept qualified (``@alias/<path>``) and listed
8
+ under ``dependencies`` in ``slice.toml``
9
+ * excluded ``@overlay`` target -> ``UnresolvedExportDependencyError`` unless
10
+ ``include_deps=True`` pulls it in transitively
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING, Any, Callable, Sequence
16
+
17
+ from ..model.addressing import MountCatalog, format_address, resolve_ref, rewrite_wikilinks
18
+ from ..model.card import ParsedCard, normalize_newlines, parse_card
19
+ from ..errors import UnresolvedExportDependencyError
20
+ from ..storage.fsutil import iter_markdown
21
+ from ..storage.history import change, event, file_action
22
+ from ..storage.card_io import read_card_file
23
+ from ..model.card import reassemble
24
+
25
+ if TYPE_CHECKING: # pragma: no cover
26
+ from ..workspace import Workspace
27
+
28
+
29
+ def rewrite_refs(text: str, parsed: ParsedCard, mapper: Callable[[str], str]) -> str:
30
+ """``text`` with every ``extends`` / ``supersedes`` / ``[[wikilink]]`` reference passed through ``mapper``.
31
+
32
+ Parts no reference touches are kept byte for byte (``reassemble``).
33
+ """
34
+ new_ext = [mapper(r) for r in parsed.extends]
35
+ new_sup = [mapper(r) for r in parsed.supersedes]
36
+ fields = None
37
+ if new_ext != parsed.extends or new_sup != parsed.supersedes:
38
+ fields = dict(parsed.frontmatter)
39
+ fields["extends"], fields["supersedes"] = new_ext, new_sup
40
+ link_map = {t: mapper(t) for t in parsed.wikilink_targets()}
41
+ link_map = {k: v for k, v in link_map.items() if k != v}
42
+ new_after = rewrite_wikilinks(parsed.after, link_map) if link_map else None
43
+ return reassemble(parsed, text, fields, new_after)
44
+
45
+
46
+ def export_text(text: str, parsed: ParsedCard, overlay_alias: str, cats: dict[str, MountCatalog]) -> str:
47
+ """The file a new ``bake()`` writes for one overlay card (source-first reference rewriting)."""
48
+
49
+ def rewrite(raw: str) -> str:
50
+ res = resolve_ref(raw, overlay_alias, cats)
51
+ if res.mount == overlay_alias:
52
+ if raw.strip().startswith("@") and res.path:
53
+ return res.path # @overlay/x -> slice-relative x
54
+ return raw # slice-relative refs keep resolving inside the exported slice
55
+ if res.mount and res.resolved and res.path:
56
+ return format_address(res.mount, res.path) # canonical qualified external ref
57
+ return raw
58
+
59
+ return rewrite_refs(text, parsed, rewrite)
60
+
61
+
62
+ def overlay_cards(ws: "Workspace", table) -> dict[str, tuple[str, ParsedCard]]:
63
+ """Every ``@overlay`` card from disk (``{path: (text, parsed)}``), skipping mounts nested inside it."""
64
+ overlay = table.overlay
65
+ nested = tuple(o.root for o in table.all() if o.alias != overlay.alias and overlay.root in o.root.parents)
66
+ cards: dict[str, tuple[str, ParsedCard]] = {}
67
+ for rel, _ in iter_markdown(overlay.root, exclude=tuple(ws._exclude()) + nested):
68
+ text, _ = read_card_file(overlay.root / f"{rel}.md")
69
+ if text is not None:
70
+ cards[rel] = (text, parse_card(text))
71
+ return cards
72
+
73
+
74
+ def dependency_closure(
75
+ cards: dict[str, tuple[str, ParsedCard]],
76
+ selected: Sequence[str],
77
+ overlay_alias: str,
78
+ cats: dict[str, MountCatalog],
79
+ include_deps: bool,
80
+ ) -> tuple[set[str], list[str], set[str], list[str]]:
81
+ """``(included, pulled, external_mounts, warnings)`` for exporting ``selected`` overlay cards.
82
+
83
+ A reference to an overlay card outside the selection raises ``UnresolvedExportDependencyError``,
84
+ unless ``include_deps`` pulls it in (transitively; ``pulled`` lists those).
85
+ """
86
+ included = set(selected)
87
+ pulled: list[str] = []
88
+ missing: list[dict[str, str]] = []
89
+ warnings: list[str] = []
90
+ external: set[str] = set()
91
+ queue = list(selected)
92
+ while queue:
93
+ path = queue.pop(0)
94
+ parsed = cards[path][1]
95
+ refs = [(k, r) for k in ("extends", "supersedes") for r in parsed.list_field(k)]
96
+ refs += [("wikilink", r) for r in parsed.wikilink_targets()]
97
+ for kind, raw in refs:
98
+ res = resolve_ref(raw, overlay_alias, cats)
99
+ if res.mount is None:
100
+ warnings.append(f"{path}: {kind} reference {raw!r} is not a valid address; copied verbatim")
101
+ continue
102
+ if res.mount != overlay_alias:
103
+ external.add(res.mount)
104
+ if not res.resolved:
105
+ warnings.append(f"{path}: {kind} reference {raw!r} does not resolve in {res.mount}")
106
+ continue
107
+ if not res.resolved or res.path not in cards:
108
+ warnings.append(f"{path}: {kind} reference {raw!r} does not resolve in {overlay_alias}; copied verbatim")
109
+ continue
110
+ if res.path in included:
111
+ continue
112
+ if include_deps:
113
+ included.add(res.path)
114
+ pulled.append(res.path)
115
+ queue.append(res.path)
116
+ else:
117
+ missing.append({"source": format_address(overlay_alias, path), "field": kind, "ref": raw,
118
+ "target": format_address(overlay_alias, res.path)})
119
+ if missing:
120
+ targets = sorted({m["target"] for m in missing})
121
+ raise UnresolvedExportDependencyError(
122
+ f"export references {len(targets)} excluded {overlay_alias} card(s): {', '.join(targets)} "
123
+ "(pass include_deps=True to pull them in)",
124
+ missing=missing,
125
+ next_steps=["Add `--include-deps` to bake those cards into the slice too, or widen `--prefix`/`--tag` "
126
+ "so the selection covers them.",
127
+ "Or remove the references to them from the selected cards, then bake again."],
128
+ )
129
+ return included, pulled, external, warnings
130
+
131
+
132
+ def retire_copies(ws: "Workspace", table, alias: str) -> dict[str, Any]:
133
+ """Remove the ``@overlay`` cards that the newly mounted slice ``alias`` holds unchanged.
134
+
135
+ Imported slices may contain copies of overlay cards; once its slice is mounted, the copies left in ``@overlay`` (higher
136
+ precedence) would shadow it and turn every ``supersedes`` they carry into competing replacements.
137
+ An overlay card is retired when the export transform of it is exactly the slice's card at the
138
+ same path: nothing is lost, the slice holds it. It is kept (and reported) when it differs (edited
139
+ since, or not an exported copy), when it has a staged change, or when a staged card references it.
140
+ References to a retired card from overlay cards that stay are rewritten to its new address
141
+ (``[[db/pool]]`` -> ``[[@db/db/pool]]``), so links and lineage keep their targets; references from
142
+ read-only mounts can't be rewritten and are reported. Runs under the caller's ``LOCK_EX``; the
143
+ caller syncs the index afterwards.
144
+ """
145
+ overlay = table.overlay
146
+ mount = table.get(alias)
147
+ idx = ws.index
148
+ cats = idx.catalogs()
149
+ staged = set(ws.staging.entries())
150
+ slice_paths = {r["path"] for r in idx.all_cards(alias)}
151
+ overlay_paths = {r["path"] for r in idx.all_cards(overlay.alias)}
152
+ retired: list[str] = []
153
+ kept: list[dict[str, str]] = []
154
+ for path in sorted(slice_paths & overlay_paths):
155
+ o_text, _ = read_card_file(overlay.root / f"{path}.md")
156
+ s_text, _ = read_card_file(mount.root / f"{path}.md")
157
+ if o_text is None or s_text is None:
158
+ continue
159
+ address = format_address(overlay.alias, path)
160
+ if path in staged:
161
+ kept.append({"address": address, "reason": "staged change pending"})
162
+ continue
163
+ exported = normalize_newlines(export_text(o_text, parse_card(o_text), overlay.alias, cats))
164
+ if normalize_newlines(s_text) not in (exported, normalize_newlines(o_text)):
165
+ kept.append({"address": address, "reason": f"differs from {format_address(alias, path)}"})
166
+ continue
167
+ retired.append(path)
168
+ return retire_overlay_paths(ws, table, alias, retired, kept, cats)
169
+
170
+
171
+ def retire_overlay_paths(ws: "Workspace", table, alias: str, retired: list[str], kept: list[dict[str, str]],
172
+ cats: dict[str, MountCatalog], *, actions: list | None = None,
173
+ events: list | None = None) -> dict[str, Any]:
174
+ """Remove the ``@overlay`` cards at ``retired`` paths, now held by slice ``alias`` at the same paths.
175
+
176
+ References to them from overlay cards that stay are rewritten to ``@alias/<path>``; a card that a
177
+ staged card references is kept instead (rewriting the overlay file would conflict) and added to
178
+ ``kept``. Runs under the caller's ``LOCK_EX``; the caller syncs the index afterwards.
179
+ """
180
+ own_transaction = actions is None
181
+ actions = [] if actions is None else actions
182
+ events = [] if events is None else events
183
+ overlay = table.overlay
184
+ idx = ws.index
185
+ staged = set(ws.staging.entries())
186
+ retired = list(retired)
187
+ kept = list(kept)
188
+
189
+ def referrers(path: str) -> list[tuple[str, str, str]]:
190
+ out = []
191
+ for kind in ("extends", "supersedes", "wikilink"):
192
+ out += [(kind, r["src_mount"], r["src_path"]) for r in idx.inbound(kind, overlay.alias, path)]
193
+ return out
194
+
195
+ blocked = True
196
+ while blocked: # a staged card that references a copy keeps it (rewriting the overlay file would conflict)
197
+ blocked = False
198
+ for path in list(retired):
199
+ stager = next((f"{m}/{p}" for _, m, p in referrers(path)
200
+ if m == overlay.alias and p in staged and p not in retired), None)
201
+ if stager:
202
+ retired.remove(path)
203
+ kept.append({"address": format_address(overlay.alias, path), "reason": f"referenced by staged {stager}"})
204
+ blocked = True
205
+ retired_set = set(retired)
206
+
207
+ def mapper(raw: str) -> str:
208
+ res = resolve_ref(raw, overlay.alias, cats)
209
+ if res.resolved and res.mount == overlay.alias and res.path in retired_set:
210
+ return format_address(alias, res.path)
211
+ return raw
212
+
213
+ rebased: list[dict[str, str]] = []
214
+ broken: list[dict[str, str]] = []
215
+ to_rewrite: set[str] = set()
216
+ for path in retired:
217
+ target = format_address(overlay.alias, path)
218
+ for kind, src_mount, src_path in referrers(path):
219
+ if src_mount == overlay.alias:
220
+ if src_path not in retired_set:
221
+ to_rewrite.add(src_path)
222
+ continue
223
+ for link in idx.outbound(src_mount, src_path, kind):
224
+ if link["resolved"] and format_address(link["dst_mount"], link["dst_path"]) == target:
225
+ item = {"address": format_address(src_mount, src_path), "kind": kind, "ref": link["raw"]}
226
+ if item not in broken:
227
+ broken.append(item)
228
+ for src_path in sorted(to_rewrite):
229
+ text, _ = read_card_file(overlay.root / f"{src_path}.md")
230
+ if text is None:
231
+ continue
232
+ parsed = parse_card(text)
233
+ address = format_address(overlay.alias, src_path)
234
+ refs = [(k, r) for k in ("extends", "supersedes") for r in parsed.list_field(k)]
235
+ refs += [("wikilink", r) for r in parsed.wikilink_targets()]
236
+ changes = [{"address": address, "kind": k, "ref": r, "to": mapper(r)} for k, r in refs if mapper(r) != r]
237
+ if parsed.errors:
238
+ broken += [{"address": address, "kind": c["kind"], "ref": c["ref"]} for c in changes]
239
+ continue
240
+ new_text = rewrite_refs(text, parsed, mapper)
241
+ if new_text != normalize_newlines(text):
242
+ action = file_action(overlay.root / f"{src_path}.md", new_text)
243
+ actions.append(action)
244
+ events.append(change("applied", address=address, op="rebase", origin="engine",
245
+ reason=f"Rebase references to cards moved into {alias}",
246
+ before_sha256=action["before_sha256"], after_sha256=action["after_sha256"]))
247
+ rebased += changes
248
+ for path in retired:
249
+ file = overlay.root / f"{path}.md"
250
+ actions.append(file_action(file, None, overlay.root))
251
+ out: dict[str, Any] = {}
252
+ if retired:
253
+ out["retired_overlay_cards"] = [format_address(overlay.alias, p) for p in retired]
254
+ if kept:
255
+ out["shadowing_overlay_cards"] = sorted(kept, key=lambda k: k["address"])
256
+ if rebased:
257
+ out["rebased_references"] = rebased
258
+ if broken:
259
+ out["broken_references"] = sorted(broken, key=lambda b: (b["address"], b["kind"], b["ref"]))
260
+ if own_transaction and (actions or events):
261
+ events.append(event("retired", alias=alias, path=str(table.get(alias).root),
262
+ retired_overlay_cards=out.get("retired_overlay_cards", [])))
263
+ ws.history.transact(actions, events)
264
+ return out
265
+
mindslice/errors.py ADDED
@@ -0,0 +1,104 @@
1
+ """Exception hierarchy for MindSlice.
2
+
3
+ Every error carries a machine-readable ``code`` and an optional ``details``
4
+ payload so CLI and MCP front-ends can serialize failures as structured JSON.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ class MindSliceError(Exception):
13
+ """Base class for all MindSlice errors."""
14
+
15
+ code = "mindslice_error"
16
+
17
+ def __init__(self, message: str, **details: Any) -> None:
18
+ super().__init__(message)
19
+ self.message = message
20
+ self.details = details
21
+
22
+ def to_dict(self) -> dict[str, Any]:
23
+ out: dict[str, Any] = {"error": type(self).__name__, "code": self.code, "message": self.message}
24
+ if self.details:
25
+ out["details"] = self.details
26
+ return out
27
+
28
+
29
+ class WorkspaceNotFoundError(MindSliceError):
30
+ code = "workspace_not_found"
31
+
32
+
33
+ class InvalidAddressError(MindSliceError, ValueError):
34
+ code = "invalid_address"
35
+
36
+
37
+ class AmbiguousAddressError(InvalidAddressError):
38
+ code = "ambiguous_address"
39
+
40
+
41
+ class NotFoundError(MindSliceError, LookupError):
42
+ code = "not_found"
43
+
44
+
45
+ class AlreadyExistsError(MindSliceError):
46
+ code = "already_exists"
47
+
48
+
49
+ class PreconditionFailedError(MindSliceError):
50
+ """Raised when ``if_match`` does not match the current revision."""
51
+
52
+ code = "precondition_failed"
53
+
54
+
55
+ class LineageCycleError(MindSliceError):
56
+ code = "lineage_cycle"
57
+
58
+
59
+ class FrontmatterError(MindSliceError, ValueError):
60
+ code = "invalid_frontmatter"
61
+
62
+
63
+ class ReadOnlyMountError(MindSliceError):
64
+ code = "read_only_mount"
65
+
66
+
67
+ class MountError(MindSliceError):
68
+ code = "mount_error"
69
+
70
+
71
+ class ApplyConflictError(MindSliceError):
72
+ """Raised by ``apply()`` when staged preconditions or graph checks fail."""
73
+
74
+ code = "apply_conflict"
75
+
76
+
77
+ class UnresolvedExportDependencyError(MindSliceError):
78
+ code = "unresolved_export_dependency"
79
+
80
+
81
+ class BakeConflictError(MindSliceError):
82
+ """Raised by ``bake()`` before writing anything, when a card can't move into the slice as it is."""
83
+
84
+ code = "bake_conflict"
85
+
86
+
87
+ class InvalidArgumentsError(MindSliceError, ValueError):
88
+ """A tool call's arguments don't fit the tool: a required one is missing, or one isn't in its schema."""
89
+
90
+ code = "invalid_arguments"
91
+
92
+
93
+ class LockTimeoutError(MindSliceError, TimeoutError):
94
+ code = "lock_timeout"
95
+
96
+
97
+ class HistoryError(MindSliceError):
98
+ code = "history_error"
99
+
100
+
101
+ class RecoveryError(MindSliceError):
102
+ """Recovery or backup cannot proceed without changing its reviewed scope."""
103
+
104
+ code = "recovery_error"
@@ -0,0 +1 @@
1
+ """CLI and MCP adapters; terminal interaction lives here."""
@@ -0,0 +1,230 @@
1
+ """Human review for workspace bake; prompts go to stderr so stdout carries only the plan or receipt."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from collections import defaultdict
6
+ from pathlib import Path
7
+
8
+ from ..baking import bake_batch
9
+ from ..model.addressing import normalize_alias
10
+ from ..errors import BakeConflictError, InvalidArgumentsError, MindSliceError
11
+ from .render import render_next_steps
12
+
13
+
14
+ def render_plan(plan, *, details=False):
15
+ lines = [f"{len(plan['cards'])} overlay cards", "", "Destination Cards Action"]
16
+ for item in plan["slices"]:
17
+ lines.append(f"{item['alias']:<18}{len(item['cards']):>5} {item['action']} → {item['path']} (v{item['version']})")
18
+ if item["dependencies_added"]:
19
+ lines.append(" New dependencies: " + ", ".join(item["dependencies_added"]))
20
+ if item.get("rebased_baseline_cards"):
21
+ lines.append(" Baseline references updated: " + ", ".join(item["rebased_baseline_cards"]))
22
+ lines += [f"Needs destination: {len(plan['unassigned'])}", f"Deferred: {len(plan['deferred'])}",
23
+ f"Remaining overlay cards after bake: {len(plan['remaining_overlay_cards'])}",
24
+ f"Staged changes excluded: {len(plan['pending_review'])}"]
25
+ if details:
26
+ for card in plan["cards"]:
27
+ lines.append(f" {card['address']} → {card['destination'] or card['state']}: {card['summary']}")
28
+ if card["state"] == "unassigned" and card["candidates"]:
29
+ lines.append(" Related slices: " + ", ".join(card["candidates"]))
30
+ for change in card.get("changes", []):
31
+ if change.get("reason"):
32
+ lines.append(" Reason: " + change["reason"])
33
+ for item in plan["retained_overlay_cards"]:
34
+ lines.append(f"Retain {item['address']}: {item['reason']}")
35
+ for item in plan["blockers"]:
36
+ extra = [repr(item["ref"])] if item.get("ref") else []
37
+ extra += [str(item[k]) for k in ("dependency", "path") if item.get(k)]
38
+ extra += item.get("errors", []) + item.get("cards", [])
39
+ lines.append(f"Blocked: {item.get('address', item.get('alias', 'workspace'))}: {item['reason']}"
40
+ + (f" ({'; '.join(extra)})" if extra else ""))
41
+ return "\n".join(lines) + "\n"
42
+
43
+
44
+ def render_receipt(result):
45
+ if result.get("cancelled"):
46
+ return f"Bake cancelled; destination choices saved in {result['plan_path']}\n"
47
+ lines = [f"Baked {len(result['baked'])} slice(s)"]
48
+ lines += [f"{item['alias']} v{item['version']} {item['path']}" for item in result["baked"]]
49
+ lines.append(f"Overlay: {len(result['remaining_overlay_cards'])} remaining cards. "
50
+ f"Staging: {result['pending_review']} pending changes.")
51
+ return "\n".join(lines) + "\n"
52
+
53
+
54
+ def _choose_paths(plan, selector):
55
+ selector = selector.removeprefix("@overlay/").removeprefix("./")
56
+ ids = [c["id"] for c in plan["cards"]]
57
+ if selector == "*":
58
+ return ids
59
+ if selector.endswith("/"):
60
+ paths = [p for p in ids if p.startswith(selector)]
61
+ else:
62
+ paths = [p for p in ids if p == selector.removesuffix(".md")]
63
+ if not paths:
64
+ raise InvalidArgumentsError("selector matches no overlay cards; use an ID, folder/ or *", selector=selector)
65
+ return paths
66
+
67
+
68
+ def _inspect(plan, paths, stream):
69
+ for c in plan["cards"]:
70
+ if c["id"] not in paths:
71
+ continue
72
+ stream.write(f"\n{c['address']} → {c['destination'] or c['state']}\n{c['summary']}\n")
73
+ for change in c.get("changes", []):
74
+ if change.get("reason"):
75
+ stream.write(f"Reason ({change['op']}): {change['reason']}\n")
76
+ stream.write(c.get("diff", "") + "\n")
77
+
78
+
79
+ def _answer(prompt):
80
+ sys.stderr.write(prompt)
81
+ sys.stderr.flush()
82
+ line = sys.stdin.readline()
83
+ if not line:
84
+ raise EOFError
85
+ return line.strip()
86
+
87
+
88
+ def review(ws, plan):
89
+ """Resume assignments by card hash, but never execute without a fresh final confirmation."""
90
+ try:
91
+ while True:
92
+ sys.stderr.write(render_plan(plan))
93
+ groups = defaultdict(list)
94
+ for c in plan["cards"]:
95
+ if c["state"] == "unassigned":
96
+ groups[(c["id"].partition("/")[0], tuple(c["candidates"]))].append(c)
97
+ for (_, candidates), cards in groups.items():
98
+ paths = [c["id"] for c in cards]
99
+ sys.stderr.write("\nNeeds destination:\n")
100
+ for c in cards:
101
+ sys.stderr.write(f" {c['id']}: {c['summary']}\n")
102
+ if candidates:
103
+ sys.stderr.write("Related slices: " + ", ".join(candidates) + "\n")
104
+ sys.stderr.write("Mounted slices: " + (", ".join(m.alias for m in ws.table().mounts) or "none") + "\n")
105
+ while True:
106
+ value = _answer("Destination alias (existing or new), inspect, split, defer, or cancel: ")
107
+ if value == "cancel":
108
+ return None
109
+ if value == "inspect":
110
+ _inspect(plan, paths, sys.stderr)
111
+ continue
112
+ if value == "split":
113
+ choices = dict(plan["assignments"])
114
+ for path in paths:
115
+ while True:
116
+ dest = _answer(f"{path}: alias, defer, or cancel: ")
117
+ if dest == "cancel":
118
+ return None
119
+ try:
120
+ choices[path] = None if dest == "defer" else normalize_alias(dest)
121
+ plan = ws.plan_bake(choices, resume=False)
122
+ bake_batch.save_plan(ws, plan)
123
+ break
124
+ except (InvalidArgumentsError, ValueError) as exc:
125
+ sys.stderr.write(str(exc) + "\n")
126
+ break
127
+ try:
128
+ dest = None if value == "defer" else normalize_alias(value)
129
+ choices = dict(plan["assignments"], **{p: dest for p in paths})
130
+ plan = ws.plan_bake(choices, resume=False)
131
+ bake_batch.save_plan(ws, plan)
132
+ break
133
+ except (InvalidArgumentsError, ValueError) as exc:
134
+ sys.stderr.write(str(exc) + "\n")
135
+ sys.stderr.write("\n" + render_plan(plan))
136
+ action = _answer("[Bake all] [Edit plan] [Inspect] [Cancel]: ").lower()
137
+ if action in ("cancel", "c", ""):
138
+ return None
139
+ if action in ("inspect", "i"):
140
+ _inspect(plan, [c["id"] for c in plan["cards"]], sys.stderr)
141
+ elif action in ("edit", "edit plan", "e"):
142
+ selector = _answer("Cards (ID, folder/, or *): ")
143
+ value = _answer("Destination alias, or defer: ")
144
+ try:
145
+ paths = _choose_paths(plan, selector)
146
+ dest = None if value == "defer" else normalize_alias(value)
147
+ plan = ws.plan_bake(dict(plan["assignments"], **{p: dest for p in paths}), resume=False)
148
+ bake_batch.save_plan(ws, plan)
149
+ except (MindSliceError, ValueError) as exc:
150
+ sys.stderr.write(str(exc) + "\n")
151
+ elif action in ("bake", "bake all", "b", "yes", "y"):
152
+ if plan["ready"]:
153
+ return plan
154
+ sys.stderr.write("Resolve the blockers or defer those cards before baking. Your plan is saved.\n"
155
+ + render_next_steps(bake_batch.next_steps(plan)))
156
+ except (EOFError, KeyboardInterrupt):
157
+ sys.stderr.write("\nReview cancelled; destination choices saved.\n")
158
+ return None
159
+
160
+
161
+ def _steps(plan, *, saved):
162
+ """Next steps for a CLI user: what blocks the plan, then how to run it.
163
+
164
+ ``saved`` is False for ``--dry-run``, which keeps no choices: its flags have to be repeated.
165
+ """
166
+ steps = bake_batch.next_steps(plan)
167
+ if steps:
168
+ return steps + [
169
+ "Destination choices made so far are saved. Finish with `mindslice bake --yes`; "
170
+ "`mindslice bake --dry-run` previews the plan." if saved else
171
+ "Once that is done, re-run this command (with any new `--assign`/`--defer` choices) and "
172
+ "replace `--dry-run` with `--yes` to bake."]
173
+ if not plan["cards"]:
174
+ return []
175
+ if saved:
176
+ return ["The plan is ready and saved: run `mindslice bake --yes` to execute it as shown, "
177
+ "or `mindslice bake` in a terminal to review it card by card."]
178
+ return ["The plan is ready: replace `--dry-run` with `--yes` to bake it as shown."]
179
+
180
+
181
+ def run(ws, args, out):
182
+ """``out(result, renderer)`` prints human text, or JSON under ``--json``."""
183
+ single_options = (args.output, args.prefix, args.tags, args.include_deps, args.name, args.description, args.slice_version)
184
+ if any(x is not None and x is not False for x in single_options):
185
+ raise InvalidArgumentsError("workspace bake uses --assign/--defer; output/filter/version options require bake @alias")
186
+ if args.plan and args.fresh:
187
+ raise InvalidArgumentsError("use either --plan or --fresh")
188
+ if args.plan:
189
+ old = bake_batch.load_plan(Path(args.plan))
190
+ plan = ws.plan_bake(old["assignments"], resume=False)
191
+ if (old.get("workspace") != plan["workspace"] or old.get("revision") != plan["revision"]
192
+ or old.get("slices") != plan["slices"]):
193
+ raise BakeConflictError("saved input plan is stale; regenerate with bake --dry-run", code="stale_bake_plan", next_steps=[
194
+ "Regenerate the plan from the current workspace: re-run the `mindslice bake ... --dry-run --json` "
195
+ f"command that produced it and save its output to {args.plan}.",
196
+ f"Review it, then run `mindslice bake --plan {args.plan} --yes`."])
197
+ else:
198
+ plan = ws.plan_bake(resume=not args.fresh)
199
+ choices = dict(plan["assignments"])
200
+ for assignment in args.assign or []:
201
+ selector, sep, alias = assignment.rpartition("=")
202
+ if not sep:
203
+ raise InvalidArgumentsError("--assign expects CARD_OR_FOLDER=ALIAS")
204
+ for path in _choose_paths(plan, selector):
205
+ choices[path] = normalize_alias(alias)
206
+ for selector in args.defer or []:
207
+ for path in _choose_paths(plan, selector):
208
+ choices[path] = None
209
+ plan = ws.plan_bake(choices, resume=False)
210
+ if args.dry_run:
211
+ out(plan, lambda p: render_plan(p, details=True) + render_next_steps(_steps(p, saved=False)))
212
+ return 0
213
+ bake_batch.save_plan(ws, plan)
214
+ if not plan["cards"]:
215
+ out({"baked": [], "remaining_overlay_cards": [], "pending_review": len(plan["pending_review"]), "dry_run": False},
216
+ render_receipt)
217
+ return 0
218
+ if not args.yes and sys.stdin.isatty() and not args.json:
219
+ plan = review(ws, plan)
220
+ if plan is None:
221
+ out({"cancelled": True, "plan_path": str(ws.root / bake_batch.PLAN_FILE)}, render_receipt)
222
+ return 0
223
+ elif not plan["ready"]:
224
+ raise BakeConflictError(bake_batch.blocked_message(plan), plan=plan,
225
+ plan_path=str(ws.root / bake_batch.PLAN_FILE), next_steps=_steps(plan, saved=True))
226
+ elif not args.yes:
227
+ raise BakeConflictError("review required; run in a terminal or use --yes with a reviewed plan",
228
+ code="bake_review_required", plan=plan, next_steps=_steps(plan, saved=True))
229
+ out(ws.bake(plan=plan), render_receipt)
230
+ return 0