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.
- mindslice/__init__.py +58 -0
- mindslice/__main__.py +5 -0
- mindslice/baking/__init__.py +1 -0
- mindslice/baking/archive.py +66 -0
- mindslice/baking/bake.py +299 -0
- mindslice/baking/bake_batch.py +537 -0
- mindslice/baking/slice_transfer.py +265 -0
- mindslice/errors.py +104 -0
- mindslice/interfaces/__init__.py +1 -0
- mindslice/interfaces/bake_review.py +230 -0
- mindslice/interfaces/cli.py +392 -0
- mindslice/interfaces/cli_addressing.py +52 -0
- mindslice/interfaces/doctor.py +184 -0
- mindslice/interfaces/mcp_server.py +536 -0
- mindslice/interfaces/render.py +346 -0
- mindslice/interfaces/staging_review.py +92 -0
- mindslice/model/__init__.py +1 -0
- mindslice/model/addressing.py +251 -0
- mindslice/model/card.py +641 -0
- mindslice/model/lineage.py +228 -0
- mindslice/model/text.py +19 -0
- mindslice/model/tomlfmt.py +129 -0
- mindslice/operations/__init__.py +1 -0
- mindslice/operations/audit.py +195 -0
- mindslice/operations/boot.py +183 -0
- mindslice/operations/impact.py +98 -0
- mindslice/operations/recovery.py +113 -0
- mindslice/operations/staging.py +641 -0
- mindslice/operations/status.py +137 -0
- mindslice/operations/validation.py +250 -0
- mindslice/operations/writer.py +481 -0
- mindslice/retrieval/__init__.py +1 -0
- mindslice/retrieval/index.py +634 -0
- mindslice/retrieval/lexical.py +215 -0
- mindslice/retrieval/query.py +593 -0
- mindslice/storage/__init__.py +1 -0
- mindslice/storage/card_io.py +14 -0
- mindslice/storage/config.py +192 -0
- mindslice/storage/fsutil.py +141 -0
- mindslice/storage/history.py +182 -0
- mindslice/storage/locking.py +125 -0
- mindslice/storage/snapshots.py +262 -0
- mindslice/workspace.py +385 -0
- mindslice-0.1.0.dist-info/METADATA +98 -0
- mindslice-0.1.0.dist-info/RECORD +50 -0
- mindslice-0.1.0.dist-info/WHEEL +5 -0
- mindslice-0.1.0.dist-info/entry_points.txt +2 -0
- mindslice-0.1.0.dist-info/licenses/LICENSE +202 -0
- mindslice-0.1.0.dist-info/licenses/NOTICE +22 -0
- 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
|