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.
Files changed (47) hide show
  1. package/LICENSE +28 -0
  2. package/NOTICE +37 -0
  3. package/README.md +143 -0
  4. package/commands/design-io.md +8 -0
  5. package/commands/ui-review.md +8 -0
  6. package/commands/ux-spec.md +8 -0
  7. package/mcp/__init__.py +0 -0
  8. package/mcp/_transport.py +242 -0
  9. package/mcp/evidence/README.md +40 -0
  10. package/mcp/evidence/__init__.py +0 -0
  11. package/mcp/evidence/server.py +450 -0
  12. package/mcp/evidence/test_server_stdio.py +645 -0
  13. package/mcp/preview/__init__.py +0 -0
  14. package/mcp/preview/browser.py +661 -0
  15. package/mcp/preview/confirm.py +255 -0
  16. package/mcp/preview/control.py +1293 -0
  17. package/mcp/preview/i18n.py +162 -0
  18. package/mcp/preview/server.py +126 -0
  19. package/mcp/preview/test_browser_control.py +663 -0
  20. package/mcp/preview/test_server_stdio.py +630 -0
  21. package/mcp/preview/test_transaction.py +436 -0
  22. package/mcp/preview/transaction.py +536 -0
  23. package/mcp/preview/util.py +19 -0
  24. package/mcp/test_transport.py +39 -0
  25. package/package.json +42 -0
  26. package/skills/craft-guard/SKILL.md +59 -0
  27. package/skills/craft-guard/references/craft.md +29 -0
  28. package/skills/craft-guard/references/detectors.md +124 -0
  29. package/skills/design-baseline/SKILL.md +134 -0
  30. package/skills/design-baseline/agents/openai.yaml +4 -0
  31. package/skills/design-baseline/references/design-template.md +73 -0
  32. package/skills/design-baseline/references/extraction-guidance.md +39 -0
  33. package/skills/design-baseline/scripts/design_baseline.py +780 -0
  34. package/skills/design-playbook/SKILL.md +219 -0
  35. package/skills/native-craft/SKILL.md +59 -0
  36. package/skills/native-craft/references/native-feel.md +79 -0
  37. package/skills/reference-intake/SKILL.md +86 -0
  38. package/skills/reference-intake/references/contract-template.md +82 -0
  39. package/skills/ui-evaluator/SKILL.md +110 -0
  40. package/skills/ui-evaluator/references/rubric.md +45 -0
  41. package/skills/ui-picker/SKILL.md +63 -0
  42. package/skills/ui-picker/references/components.md +31 -0
  43. package/skills/ui-picker/references/design.md +21 -0
  44. package/skills/ui-picker/references/domain.md +26 -0
  45. package/skills/ui-picker/references/template.md +24 -0
  46. package/skills/ux-spec/SKILL.md +51 -0
  47. 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
+ }