design-playbook 0.7.0
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.
- package/LICENSE +28 -0
- package/NOTICE +37 -0
- package/README.md +143 -0
- package/commands/design-io.md +8 -0
- package/commands/ui-review.md +8 -0
- package/commands/ux-spec.md +8 -0
- package/mcp/__init__.py +0 -0
- package/mcp/_transport.py +242 -0
- package/mcp/evidence/README.md +40 -0
- package/mcp/evidence/__init__.py +0 -0
- package/mcp/evidence/server.py +450 -0
- package/mcp/evidence/test_server_stdio.py +645 -0
- package/mcp/preview/__init__.py +0 -0
- package/mcp/preview/browser.py +661 -0
- package/mcp/preview/confirm.py +255 -0
- package/mcp/preview/control.py +1293 -0
- package/mcp/preview/i18n.py +162 -0
- package/mcp/preview/server.py +126 -0
- package/mcp/preview/test_browser_control.py +663 -0
- package/mcp/preview/test_server_stdio.py +630 -0
- package/mcp/preview/test_transaction.py +436 -0
- package/mcp/preview/transaction.py +536 -0
- package/mcp/preview/util.py +19 -0
- package/mcp/test_transport.py +39 -0
- package/package.json +42 -0
- package/skills/craft-guard/SKILL.md +59 -0
- package/skills/craft-guard/references/craft.md +29 -0
- package/skills/craft-guard/references/detectors.md +124 -0
- package/skills/design-baseline/SKILL.md +134 -0
- package/skills/design-baseline/agents/openai.yaml +4 -0
- package/skills/design-baseline/references/design-template.md +73 -0
- package/skills/design-baseline/references/extraction-guidance.md +39 -0
- package/skills/design-baseline/scripts/design_baseline.py +780 -0
- package/skills/design-playbook/SKILL.md +219 -0
- package/skills/native-craft/SKILL.md +59 -0
- package/skills/native-craft/references/native-feel.md +79 -0
- package/skills/reference-intake/SKILL.md +86 -0
- package/skills/reference-intake/references/contract-template.md +82 -0
- package/skills/ui-evaluator/SKILL.md +110 -0
- package/skills/ui-evaluator/references/rubric.md +45 -0
- package/skills/ui-picker/SKILL.md +63 -0
- package/skills/ui-picker/references/components.md +31 -0
- package/skills/ui-picker/references/design.md +21 -0
- package/skills/ui-picker/references/domain.md +26 -0
- package/skills/ui-picker/references/template.md +24 -0
- package/skills/ux-spec/SKILL.md +51 -0
- package/skills/ux-spec/references/spec-template.md +43 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Durable Preview decision authority and artifact transaction.
|
|
2
|
+
|
|
3
|
+
Browser collectors return authenticated submission data. This module owns
|
|
4
|
+
request binding, choice authority, atomic persistence, recovery, projections,
|
|
5
|
+
and result construction for one Preview decision.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Iterator
|
|
19
|
+
|
|
20
|
+
from confirm import (
|
|
21
|
+
_check_feedback_floor,
|
|
22
|
+
_ensure_prototype,
|
|
23
|
+
_preview_dir_for,
|
|
24
|
+
prototype_html_digest,
|
|
25
|
+
)
|
|
26
|
+
from control import _format_feedback
|
|
27
|
+
from i18n import CONFIRM_LABELS
|
|
28
|
+
from util import _now_iso
|
|
29
|
+
|
|
30
|
+
BrowserCollector = Callable[[Path, str, list[str], int], dict[str, Any]]
|
|
31
|
+
ENTRY_SCHEMA_VERSION = 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PreviewTransactionError(ValueError):
|
|
35
|
+
"""Recoverable transaction failure with actionable artifact context."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self, message: str, *, retryable: bool, round_n: int,
|
|
39
|
+
decision_id: str, artifact: str,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.details = {
|
|
43
|
+
"error": "preview_transaction",
|
|
44
|
+
"message": message,
|
|
45
|
+
"retryable": retryable,
|
|
46
|
+
"round": round_n,
|
|
47
|
+
"decision_id": decision_id,
|
|
48
|
+
"artifact": artifact,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TransactionConflict(PreviewTransactionError):
|
|
53
|
+
"""Existing same-round authority cannot be replaced by this request."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
LOCK_HEARTBEAT_SECONDS = 30
|
|
57
|
+
LOCK_STALE_SECONDS = LOCK_HEARTBEAT_SECONDS * 3
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _lock_metadata(path: Path) -> dict[str, Any]:
|
|
61
|
+
try:
|
|
62
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
63
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
64
|
+
return {}
|
|
65
|
+
return value if isinstance(value, dict) else {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _claim_stale_lock(
|
|
69
|
+
path: Path, *, binding_digest: str, round_n: int, decision_id: str,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Serialize stale takeover so one recoverer cannot delete another's lock."""
|
|
72
|
+
guard = path.with_suffix(path.suffix + ".recovery")
|
|
73
|
+
try:
|
|
74
|
+
fd = os.open(guard, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
75
|
+
except FileExistsError as exc:
|
|
76
|
+
raise PreviewTransactionError(
|
|
77
|
+
f"Preview round {round_n} recovery is already active",
|
|
78
|
+
retryable=True, round_n=round_n, decision_id=decision_id,
|
|
79
|
+
artifact=str(guard),
|
|
80
|
+
) from exc
|
|
81
|
+
os.close(fd)
|
|
82
|
+
try:
|
|
83
|
+
existing = _lock_metadata(path)
|
|
84
|
+
try:
|
|
85
|
+
age = time.time() - path.stat().st_mtime
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
return
|
|
88
|
+
if age < LOCK_STALE_SECONDS:
|
|
89
|
+
raise PreviewTransactionError(
|
|
90
|
+
f"Preview round {round_n} is already active",
|
|
91
|
+
retryable=True, round_n=round_n,
|
|
92
|
+
decision_id=str(existing.get("decision_id") or decision_id),
|
|
93
|
+
artifact=str(path),
|
|
94
|
+
)
|
|
95
|
+
if existing.get("binding_digest") != binding_digest:
|
|
96
|
+
raise TransactionConflict(
|
|
97
|
+
f"stale lock binding differs; use next round: {round_n}",
|
|
98
|
+
retryable=False, round_n=round_n,
|
|
99
|
+
decision_id=str(existing.get("decision_id") or decision_id),
|
|
100
|
+
artifact=str(path),
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
path.unlink()
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
pass
|
|
106
|
+
finally:
|
|
107
|
+
try:
|
|
108
|
+
guard.unlink()
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@contextmanager
|
|
114
|
+
def _round_lock(
|
|
115
|
+
preview_dir: Path, *, round_n: int, binding_digest: str, decision_id: str,
|
|
116
|
+
) -> Iterator[None]:
|
|
117
|
+
preview_dir.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
path = preview_dir / f"decision-round-{round_n}.lock"
|
|
119
|
+
owner_id = uuid.uuid4().hex
|
|
120
|
+
metadata = {
|
|
121
|
+
"owner_id": owner_id,
|
|
122
|
+
"decision_id": decision_id,
|
|
123
|
+
"binding_digest": binding_digest,
|
|
124
|
+
"heartbeat": time.time(),
|
|
125
|
+
}
|
|
126
|
+
raw = json.dumps(metadata, sort_keys=True)
|
|
127
|
+
while True:
|
|
128
|
+
try:
|
|
129
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
130
|
+
except FileExistsError:
|
|
131
|
+
_claim_stale_lock(
|
|
132
|
+
path, binding_digest=binding_digest,
|
|
133
|
+
round_n=round_n, decision_id=decision_id,
|
|
134
|
+
)
|
|
135
|
+
continue
|
|
136
|
+
else:
|
|
137
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
138
|
+
fh.write(raw)
|
|
139
|
+
fh.flush()
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
stopped = threading.Event()
|
|
143
|
+
heartbeat_errors: list[OSError] = []
|
|
144
|
+
|
|
145
|
+
def heartbeat() -> None:
|
|
146
|
+
while not stopped.wait(LOCK_HEARTBEAT_SECONDS):
|
|
147
|
+
current = _lock_metadata(path)
|
|
148
|
+
if current.get("owner_id") != owner_id:
|
|
149
|
+
return
|
|
150
|
+
metadata["heartbeat"] = time.time()
|
|
151
|
+
try:
|
|
152
|
+
_atomic_write(path, json.dumps(metadata, sort_keys=True))
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
heartbeat_errors.append(exc)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
thread = threading.Thread(target=heartbeat, daemon=True)
|
|
158
|
+
thread.start()
|
|
159
|
+
try:
|
|
160
|
+
yield
|
|
161
|
+
if heartbeat_errors:
|
|
162
|
+
raise PreviewTransactionError(
|
|
163
|
+
f"Preview lock heartbeat failed: {heartbeat_errors[0]}",
|
|
164
|
+
retryable=True, round_n=round_n, decision_id=decision_id,
|
|
165
|
+
artifact=str(path),
|
|
166
|
+
)
|
|
167
|
+
finally:
|
|
168
|
+
stopped.set()
|
|
169
|
+
thread.join(timeout=1)
|
|
170
|
+
if _lock_metadata(path).get("owner_id") == owner_id:
|
|
171
|
+
try:
|
|
172
|
+
path.unlink()
|
|
173
|
+
except FileNotFoundError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _atomic_write(path: Path, content: str) -> None:
|
|
178
|
+
"""Flush a same-directory temporary file before atomically replacing path."""
|
|
179
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
181
|
+
try:
|
|
182
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
|
|
183
|
+
fh.write(content)
|
|
184
|
+
fh.flush()
|
|
185
|
+
os.fsync(fh.fileno())
|
|
186
|
+
os.replace(temp_name, path)
|
|
187
|
+
except BaseException:
|
|
188
|
+
try:
|
|
189
|
+
os.unlink(temp_name)
|
|
190
|
+
except FileNotFoundError:
|
|
191
|
+
pass
|
|
192
|
+
raise
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _json_text(value: dict[str, Any]) -> str:
|
|
196
|
+
return json.dumps(value, ensure_ascii=False, indent=2) + "\n"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _binding(
|
|
200
|
+
*, round_n: int, prototype_hash: str, report_ref: str,
|
|
201
|
+
summary: str, options: list[str],
|
|
202
|
+
) -> dict[str, Any]:
|
|
203
|
+
fields = {
|
|
204
|
+
"round": round_n,
|
|
205
|
+
"prototype_html_hash": prototype_hash,
|
|
206
|
+
"report_ref": report_ref,
|
|
207
|
+
"summary": summary,
|
|
208
|
+
"options": list(options),
|
|
209
|
+
}
|
|
210
|
+
canonical = json.dumps(
|
|
211
|
+
fields, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
212
|
+
).encode("utf-8")
|
|
213
|
+
return {"digest": hashlib.sha256(canonical).hexdigest(), **fields}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _load_entry(path: Path) -> dict[str, Any] | None:
|
|
217
|
+
if not path.is_file():
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
entry = json.loads(path.read_text(encoding="utf-8"))
|
|
221
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
222
|
+
raise TransactionConflict(
|
|
223
|
+
f"round decision metadata is unreadable; use next round: {path}",
|
|
224
|
+
retryable=False, round_n=_round_from_path(path), decision_id="",
|
|
225
|
+
artifact=str(path),
|
|
226
|
+
) from exc
|
|
227
|
+
required = {
|
|
228
|
+
"schema_version", "decision_id", "timestamp", "binding", "outcome"
|
|
229
|
+
}
|
|
230
|
+
round_n = _round_from_path(path)
|
|
231
|
+
binding = entry.get("binding") if isinstance(entry, dict) else None
|
|
232
|
+
outcome = entry.get("outcome") if isinstance(entry, dict) else None
|
|
233
|
+
binding_valid = False
|
|
234
|
+
if isinstance(binding, dict):
|
|
235
|
+
try:
|
|
236
|
+
expected = _binding(
|
|
237
|
+
round_n=round_n,
|
|
238
|
+
prototype_hash=binding["prototype_html_hash"],
|
|
239
|
+
report_ref=binding["report_ref"], summary=binding["summary"],
|
|
240
|
+
options=binding["options"],
|
|
241
|
+
)
|
|
242
|
+
binding_valid = binding == expected
|
|
243
|
+
except (KeyError, TypeError):
|
|
244
|
+
binding_valid = False
|
|
245
|
+
if (
|
|
246
|
+
not isinstance(entry, dict)
|
|
247
|
+
or entry.get("schema_version") != ENTRY_SCHEMA_VERSION
|
|
248
|
+
or not required.issubset(entry)
|
|
249
|
+
or not isinstance(entry.get("decision_id"), str)
|
|
250
|
+
or not entry["decision_id"]
|
|
251
|
+
or not isinstance(entry.get("timestamp"), str)
|
|
252
|
+
or not binding_valid
|
|
253
|
+
or not isinstance(outcome, dict)
|
|
254
|
+
or not isinstance(outcome.get("selected_options"), list)
|
|
255
|
+
or not isinstance(outcome.get("anchors"), list)
|
|
256
|
+
or not isinstance(outcome.get("feedback"), str)
|
|
257
|
+
or not isinstance(outcome.get("confirmed"), bool)
|
|
258
|
+
or not isinstance(outcome.get("user_confirmed"), bool)
|
|
259
|
+
or not isinstance(outcome.get("floor_pass"), bool)
|
|
260
|
+
or not isinstance(outcome.get("aborted"), bool)
|
|
261
|
+
):
|
|
262
|
+
raise TransactionConflict(
|
|
263
|
+
f"round decision metadata is invalid; use next round: {path}",
|
|
264
|
+
retryable=False, round_n=round_n, decision_id="",
|
|
265
|
+
artifact=str(path),
|
|
266
|
+
)
|
|
267
|
+
return entry
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _round_from_path(path: Path) -> int:
|
|
271
|
+
try:
|
|
272
|
+
return int(path.stem.rsplit("-", 1)[1])
|
|
273
|
+
except (IndexError, ValueError):
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _confirm_record(entry: dict[str, Any]) -> dict[str, Any]:
|
|
278
|
+
binding = entry["binding"]
|
|
279
|
+
outcome = entry["outcome"]
|
|
280
|
+
record: dict[str, Any] = {
|
|
281
|
+
"round": binding["round"],
|
|
282
|
+
"report_ref": binding["report_ref"],
|
|
283
|
+
"confirmed": outcome["confirmed"],
|
|
284
|
+
"floor_pass": outcome["floor_pass"],
|
|
285
|
+
"selected_options": outcome["selected_options"],
|
|
286
|
+
"feedback": outcome["feedback"],
|
|
287
|
+
"timestamp": entry["timestamp"],
|
|
288
|
+
"prototype_path": f"preview/round-{binding['round']}.html",
|
|
289
|
+
"prototype_html_hash": binding["prototype_html_hash"],
|
|
290
|
+
"decision_id": entry["decision_id"],
|
|
291
|
+
}
|
|
292
|
+
if outcome.get("floor_failure"):
|
|
293
|
+
record["floor_failure"] = outcome["floor_failure"]
|
|
294
|
+
return record
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _render_log(entries: list[dict[str, Any]]) -> str:
|
|
298
|
+
blocks = ["# preview log\n"]
|
|
299
|
+
for entry in sorted(
|
|
300
|
+
entries, key=lambda item: (str(item["timestamp"]), str(item["decision_id"]))
|
|
301
|
+
):
|
|
302
|
+
binding = entry["binding"]
|
|
303
|
+
outcome = entry["outcome"]
|
|
304
|
+
anchors = list(outcome.get("anchors") or [])
|
|
305
|
+
lines = [
|
|
306
|
+
"",
|
|
307
|
+
f"## round {binding['round']}",
|
|
308
|
+
f"- report_ref: {binding['report_ref']}",
|
|
309
|
+
f"- timestamp: {entry['timestamp']}",
|
|
310
|
+
f"- decision_id: {entry['decision_id']}",
|
|
311
|
+
f"- feedback: {outcome.get('feedback') or ''}",
|
|
312
|
+
f"- selected: {', '.join(outcome.get('selected_options') or [])}",
|
|
313
|
+
f"- aborted: {str(bool(outcome.get('aborted'))).lower()}",
|
|
314
|
+
f"- anchors: {len(anchors)}",
|
|
315
|
+
f"- floor_pass: {str(bool(outcome.get('floor_pass'))).lower()}",
|
|
316
|
+
]
|
|
317
|
+
if outcome.get("floor_failure"):
|
|
318
|
+
lines.append(f"- floor_failure: {outcome['floor_failure']}")
|
|
319
|
+
if outcome.get("rejected"):
|
|
320
|
+
lines.append("- rejected: true")
|
|
321
|
+
if outcome.get("rejection"):
|
|
322
|
+
lines.append(f"- rejection: {outcome['rejection']}")
|
|
323
|
+
for index, anchor in enumerate(anchors, 1):
|
|
324
|
+
lines.append(
|
|
325
|
+
f" - [{index}] {anchor.get('selector') or ''} | "
|
|
326
|
+
f"{anchor.get('label') or ''} | {anchor.get('comment') or ''}"
|
|
327
|
+
)
|
|
328
|
+
blocks.append("\n".join(lines) + "\n")
|
|
329
|
+
return "".join(blocks)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _valid_entries(preview_dir: Path) -> list[dict[str, Any]]:
|
|
333
|
+
entries: list[dict[str, Any]] = []
|
|
334
|
+
for path in preview_dir.glob("decision-round-*.json"):
|
|
335
|
+
entry = _load_entry(path)
|
|
336
|
+
if entry is not None:
|
|
337
|
+
entries.append(entry)
|
|
338
|
+
return entries
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _commit_projections(preview_dir: Path, entry: dict[str, Any]) -> str:
|
|
342
|
+
binding = entry["binding"]
|
|
343
|
+
outcome = entry["outcome"]
|
|
344
|
+
confirm_path = preview_dir / f"confirm-round-{binding['round']}.json"
|
|
345
|
+
if outcome["user_confirmed"]:
|
|
346
|
+
existing_confirm = None
|
|
347
|
+
if confirm_path.is_file():
|
|
348
|
+
try:
|
|
349
|
+
existing_confirm = json.loads(confirm_path.read_text(encoding="utf-8"))
|
|
350
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
351
|
+
existing_confirm = None
|
|
352
|
+
if existing_confirm and existing_confirm.get("decision_id") not in {
|
|
353
|
+
None, entry["decision_id"]
|
|
354
|
+
}:
|
|
355
|
+
raise TransactionConflict(
|
|
356
|
+
f"confirm belongs to another decision; use next round: {confirm_path}",
|
|
357
|
+
retryable=False, round_n=int(binding["round"]),
|
|
358
|
+
decision_id=str(entry["decision_id"]), artifact=str(confirm_path),
|
|
359
|
+
)
|
|
360
|
+
if existing_confirm and "decision_id" not in existing_confirm:
|
|
361
|
+
raise TransactionConflict(
|
|
362
|
+
f"legacy confirm cannot be overwritten; use next round: {confirm_path}",
|
|
363
|
+
retryable=False, round_n=int(binding["round"]),
|
|
364
|
+
decision_id=str(entry["decision_id"]), artifact=str(confirm_path),
|
|
365
|
+
)
|
|
366
|
+
_atomic_write(confirm_path, _json_text(_confirm_record(entry)))
|
|
367
|
+
confirm_result = str(confirm_path)
|
|
368
|
+
else:
|
|
369
|
+
confirm_result = ""
|
|
370
|
+
_atomic_write(preview_dir / "log.md", _render_log(_valid_entries(preview_dir)))
|
|
371
|
+
return confirm_result
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _result(entry: dict[str, Any], confirm_path: str) -> dict[str, Any]:
|
|
375
|
+
binding = entry["binding"]
|
|
376
|
+
outcome = entry["outcome"]
|
|
377
|
+
return {
|
|
378
|
+
"confirmed": outcome["confirmed"],
|
|
379
|
+
"floor_pass": outcome["floor_pass"],
|
|
380
|
+
"selected_options": list(outcome["selected_options"]),
|
|
381
|
+
"feedback": outcome["feedback"],
|
|
382
|
+
"anchors": list(outcome["anchors"]),
|
|
383
|
+
"round": binding["round"],
|
|
384
|
+
"confirm_record_path": confirm_path,
|
|
385
|
+
"aborted": outcome["aborted"],
|
|
386
|
+
"decision_id": entry["decision_id"],
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def run_preview_transaction(
|
|
391
|
+
*,
|
|
392
|
+
path_arg: str | None,
|
|
393
|
+
html: str | None,
|
|
394
|
+
summary: str,
|
|
395
|
+
round_n: int,
|
|
396
|
+
report_ref: str,
|
|
397
|
+
options: list[str],
|
|
398
|
+
collect: BrowserCollector,
|
|
399
|
+
) -> dict[str, Any]:
|
|
400
|
+
"""Serialize, collect once, or repair one bound Preview decision."""
|
|
401
|
+
summary = summary.strip()
|
|
402
|
+
report_ref = report_ref.strip()
|
|
403
|
+
preview_dir = _preview_dir_for(Path(path_arg) if path_arg else None)
|
|
404
|
+
if path_arg:
|
|
405
|
+
prototype = Path(path_arg)
|
|
406
|
+
if not prototype.is_file():
|
|
407
|
+
raise ValueError(f"prototype path does not exist: {path_arg}")
|
|
408
|
+
prototype_hash = prototype_html_digest(prototype.read_bytes())
|
|
409
|
+
else:
|
|
410
|
+
if not html:
|
|
411
|
+
raise ValueError("path or html is required")
|
|
412
|
+
prototype_hash = prototype_html_digest(html.encode("utf-8"))
|
|
413
|
+
binding = _binding(
|
|
414
|
+
round_n=round_n, prototype_hash=prototype_hash,
|
|
415
|
+
report_ref=report_ref, summary=summary, options=options,
|
|
416
|
+
)
|
|
417
|
+
entry_path = preview_dir / f"decision-round-{round_n}.json"
|
|
418
|
+
existing = _load_entry(entry_path)
|
|
419
|
+
decision_id = str(existing.get("decision_id") if existing else uuid.uuid4().hex)
|
|
420
|
+
try:
|
|
421
|
+
with _round_lock(
|
|
422
|
+
preview_dir, round_n=round_n,
|
|
423
|
+
binding_digest=binding["digest"], decision_id=decision_id,
|
|
424
|
+
):
|
|
425
|
+
return _run_locked(
|
|
426
|
+
path_arg=path_arg, html=html, summary=summary, round_n=round_n,
|
|
427
|
+
report_ref=report_ref, options=options, collect=collect,
|
|
428
|
+
preview_dir=preview_dir, prototype_hash=prototype_hash,
|
|
429
|
+
binding=binding, decision_id=decision_id,
|
|
430
|
+
)
|
|
431
|
+
except PreviewTransactionError:
|
|
432
|
+
raise
|
|
433
|
+
except OSError as exc:
|
|
434
|
+
confirm_path = preview_dir / f"confirm-round-{round_n}.json"
|
|
435
|
+
log_path = preview_dir / "log.md"
|
|
436
|
+
if not entry_path.is_file():
|
|
437
|
+
artifact = entry_path
|
|
438
|
+
else:
|
|
439
|
+
entry = _load_entry(entry_path)
|
|
440
|
+
needs_confirm = bool(entry and entry["outcome"].get("user_confirmed"))
|
|
441
|
+
if needs_confirm and not confirm_path.is_file():
|
|
442
|
+
artifact = confirm_path
|
|
443
|
+
else:
|
|
444
|
+
artifact = log_path
|
|
445
|
+
raise PreviewTransactionError(
|
|
446
|
+
f"Preview persistence incomplete: {exc}", retryable=True,
|
|
447
|
+
round_n=round_n, decision_id=decision_id, artifact=str(artifact),
|
|
448
|
+
) from exc
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _run_locked(
|
|
452
|
+
*, path_arg: str | None, html: str | None, summary: str, round_n: int,
|
|
453
|
+
report_ref: str, options: list[str], collect: BrowserCollector,
|
|
454
|
+
preview_dir: Path, prototype_hash: str, binding: dict[str, Any],
|
|
455
|
+
decision_id: str,
|
|
456
|
+
) -> dict[str, Any]:
|
|
457
|
+
entry_path = preview_dir / f"decision-round-{round_n}.json"
|
|
458
|
+
if path_arg:
|
|
459
|
+
prototype = Path(path_arg)
|
|
460
|
+
if not prototype.is_file():
|
|
461
|
+
raise ValueError(f"prototype path does not exist: {path_arg}")
|
|
462
|
+
prototype_hash = prototype_html_digest(prototype.read_bytes())
|
|
463
|
+
else:
|
|
464
|
+
if not html:
|
|
465
|
+
raise ValueError("path or html is required")
|
|
466
|
+
prototype_hash = prototype_html_digest(html.encode("utf-8"))
|
|
467
|
+
prototype = preview_dir / f"round-{round_n}.html"
|
|
468
|
+
|
|
469
|
+
existing = _load_entry(entry_path)
|
|
470
|
+
if existing is not None:
|
|
471
|
+
if existing["binding"].get("digest") != binding["digest"]:
|
|
472
|
+
raise TransactionConflict(
|
|
473
|
+
f"round binding differs from durable decision; use next round: {round_n}",
|
|
474
|
+
retryable=False, round_n=round_n,
|
|
475
|
+
decision_id=str(existing["decision_id"]), artifact=str(entry_path),
|
|
476
|
+
)
|
|
477
|
+
confirm_path = _commit_projections(preview_dir, existing)
|
|
478
|
+
return _result(existing, confirm_path)
|
|
479
|
+
|
|
480
|
+
legacy_confirm = preview_dir / f"confirm-round-{round_n}.json"
|
|
481
|
+
if legacy_confirm.is_file():
|
|
482
|
+
raise TransactionConflict(
|
|
483
|
+
f"legacy confirm cannot be overwritten; use next round: {legacy_confirm}",
|
|
484
|
+
retryable=False, round_n=round_n, decision_id=decision_id,
|
|
485
|
+
artifact=str(legacy_confirm),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
prototype = _ensure_prototype(path_arg, html, round_n, preview_dir)
|
|
489
|
+
submission = collect(prototype, summary, options, round_n)
|
|
490
|
+
anchors = list(submission.get("anchors") or [])
|
|
491
|
+
raw_feedback = str(submission.get("feedback") or "")
|
|
492
|
+
feedback = _format_feedback(raw_feedback, anchors)
|
|
493
|
+
rejected = bool(submission.get("rejected"))
|
|
494
|
+
aborted = bool(submission.get("aborted"))
|
|
495
|
+
choice = str(submission.get("choice") or "")
|
|
496
|
+
selected = [] if aborted or rejected or not choice else [choice]
|
|
497
|
+
|
|
498
|
+
confirm_labels = {label.casefold() for label in CONFIRM_LABELS}
|
|
499
|
+
user_confirmed = (
|
|
500
|
+
not aborted and not rejected and choice.casefold() in confirm_labels
|
|
501
|
+
)
|
|
502
|
+
if rejected:
|
|
503
|
+
floor_pass = False
|
|
504
|
+
floor_failure = str(submission.get("floor_failure") or "")
|
|
505
|
+
else:
|
|
506
|
+
floor_pass, floor_failure = _check_feedback_floor(raw_feedback, anchors)
|
|
507
|
+
confirmed = user_confirmed and floor_pass
|
|
508
|
+
|
|
509
|
+
served_hash = str(submission.get("prototype_html_hash") or prototype_hash)
|
|
510
|
+
if served_hash != prototype_hash:
|
|
511
|
+
raise TransactionConflict(
|
|
512
|
+
"served prototype hash differs from request binding",
|
|
513
|
+
retryable=False, round_n=round_n, decision_id=decision_id,
|
|
514
|
+
artifact=str(prototype),
|
|
515
|
+
)
|
|
516
|
+
entry = {
|
|
517
|
+
"schema_version": ENTRY_SCHEMA_VERSION,
|
|
518
|
+
"decision_id": decision_id,
|
|
519
|
+
"timestamp": _now_iso(),
|
|
520
|
+
"binding": binding,
|
|
521
|
+
"outcome": {
|
|
522
|
+
"confirmed": confirmed,
|
|
523
|
+
"user_confirmed": user_confirmed,
|
|
524
|
+
"floor_pass": floor_pass,
|
|
525
|
+
"floor_failure": floor_failure,
|
|
526
|
+
"selected_options": selected,
|
|
527
|
+
"feedback": feedback,
|
|
528
|
+
"anchors": anchors,
|
|
529
|
+
"aborted": aborted,
|
|
530
|
+
"rejected": rejected,
|
|
531
|
+
"rejection": str(submission.get("rejection") or ""),
|
|
532
|
+
},
|
|
533
|
+
}
|
|
534
|
+
_atomic_write(entry_path, _json_text(entry))
|
|
535
|
+
confirm_path = _commit_projections(preview_dir, entry)
|
|
536
|
+
return _result(entry, confirm_path)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared leaf helpers for the preview adapter (logging, timestamps).
|
|
2
|
+
|
|
3
|
+
Sibling to i18n.py; imported by server.py, browser.py, confirm.py.
|
|
4
|
+
No third-party deps.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _log(msg: str) -> None:
|
|
13
|
+
print(msg, file=sys.stderr, flush=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _now_iso() -> str:
|
|
18
|
+
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
|
19
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Focused contract tests for shared MCP tool-result mapping."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
10
|
+
from _transport import ToolError, _exception_result # noqa: E402
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TransportToolErrorTests(unittest.TestCase):
|
|
14
|
+
def test_typed_error_preserves_readable_and_structured_content(self) -> None:
|
|
15
|
+
details = {
|
|
16
|
+
"error": "preview_transaction",
|
|
17
|
+
"retryable": True,
|
|
18
|
+
"round": 2,
|
|
19
|
+
"decision_id": "abc",
|
|
20
|
+
"artifact": "decision-round-2.json",
|
|
21
|
+
}
|
|
22
|
+
result = _exception_result(ToolError("repair required", details))
|
|
23
|
+
|
|
24
|
+
self.assertTrue(result["isError"])
|
|
25
|
+
self.assertEqual(result["content"][0]["text"], "repair required")
|
|
26
|
+
self.assertEqual(result["structuredContent"], details)
|
|
27
|
+
|
|
28
|
+
def test_ordinary_exception_mapping_is_unchanged(self) -> None:
|
|
29
|
+
self.assertEqual(
|
|
30
|
+
_exception_result(ValueError("bad argument")),
|
|
31
|
+
{
|
|
32
|
+
"content": [{"type": "text", "text": "bad argument"}],
|
|
33
|
+
"isError": True,
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
unittest.main()
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "design-playbook",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Design I/O for coding agents: controllable UI generation via declarations (spec/domain/craft/design/components/template) and contracts (skill/evaluator). Use for product UI—console, dashboard, agent-ops, CJK-first apps.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"ui",
|
|
8
|
+
"ux",
|
|
9
|
+
"design",
|
|
10
|
+
"design-io",
|
|
11
|
+
"design-playbook",
|
|
12
|
+
"spec",
|
|
13
|
+
"craft",
|
|
14
|
+
"evaluator",
|
|
15
|
+
"console",
|
|
16
|
+
"cjk"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Bandersnatch0x (https://github.com/Bandersnatch0x)",
|
|
20
|
+
"homepage": "https://github.com/Bandersnatch0x/design-playbook",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/Bandersnatch0x/design-playbook.git",
|
|
24
|
+
"directory": "packages/design-playbook"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"skills",
|
|
28
|
+
"commands",
|
|
29
|
+
"mcp",
|
|
30
|
+
"NOTICE",
|
|
31
|
+
"!**/__pycache__"
|
|
32
|
+
],
|
|
33
|
+
"pi": {
|
|
34
|
+
"skills": [
|
|
35
|
+
"./skills"
|
|
36
|
+
],
|
|
37
|
+
"prompts": [
|
|
38
|
+
"./commands"
|
|
39
|
+
],
|
|
40
|
+
"image": "https://raw.githubusercontent.com/Bandersnatch0x/design-playbook/main/packages/design-playbook/showcase/screenshots/hero.png"
|
|
41
|
+
}
|
|
42
|
+
}
|