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,450 @@
1
+ #!/usr/bin/env python3
2
+ """Minimal stdio MCP server: single tool `execute_capture_plan`.
3
+
4
+ Evidence Provider adapter (ticket 02). Captures artifacts via Playwright.
5
+ Never writes manifest.jsonl; never accepts criterion refs (orchestrator binds).
6
+ Returns relative ``artifact`` plus absolute ``written_path`` so RUN_ROOT/cwd
7
+ misconfig is visible to the orchestrator.
8
+
9
+ Run (plugin-bundled MCP config uses ${CLAUDE_PLUGIN_ROOT}):
10
+ { "command": "python", "args": ["<plugin>/mcp/evidence/server.py"],
11
+ "env": {"DESIGN_PLAYBOOK_RUN_ROOT": "."} }
12
+ DESIGN_PLAYBOOK_RUN_ROOT="." is process-cwd-relative — point it at the
13
+ absolute run root when the host workspace is not the MCP cwd.
14
+ Compatibility launcher remains at packages/design-playbook-evidence/server.py.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from pathlib import Path, PurePosixPath, PureWindowsPath
22
+ from typing import Any
23
+
24
+ # Shared stdio JSON-RPC framing + single-tool dispatch live one level up in
25
+ # mcp/_transport.py (both bundled adapters speak the same wire format and
26
+ # run the same JSON-RPC protocol; ADR-0009).
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
28
+ from _transport import serve_stdio # noqa: E402
29
+
30
+ TOOL_NAME = "execute_capture_plan"
31
+ SERVER_NAME = "design-playbook-evidence"
32
+ SERVER_VERSION = "0.1.0"
33
+ CAPTURE_TYPES = frozenset({"screenshot", "a11y tree", "interaction trace"})
34
+ ALLOWED_ARGUMENTS = frozenset(
35
+ {"url", "type", "state", "actions", "artifact_path", "overwrite"}
36
+ )
37
+ RUN_ROOT_ENV = "DESIGN_PLAYBOOK_RUN_ROOT"
38
+ EVIDENCE_SUBDIR = "evidence"
39
+
40
+
41
+ def _log(msg: str) -> None:
42
+ print(msg, file=sys.stderr, flush=True)
43
+
44
+
45
+ def _tool_schema() -> dict[str, Any]:
46
+ return {
47
+ "name": TOOL_NAME,
48
+ "description": (
49
+ "Execute a capture plan snapshot: navigate, run actions, write one "
50
+ "artifact (screenshot / a11y tree / interaction trace). Returns "
51
+ "capture result only (artifact, observed_state, result, error, "
52
+ "written_path absolute) — never writes manifest; never judges criteria."
53
+ ),
54
+ "inputSchema": {
55
+ "type": "object",
56
+ "properties": {
57
+ "url": {
58
+ "type": "string",
59
+ "description": "Target host URL (or file://) to capture.",
60
+ },
61
+ "type": {
62
+ "type": "string",
63
+ "description": 'v1: "screenshot" | "a11y tree" | "interaction trace".',
64
+ "enum": ["screenshot", "a11y tree", "interaction trace"],
65
+ },
66
+ "state": {
67
+ "type": "string",
68
+ "description": "Expected page state label (error/loading/ok/...).",
69
+ },
70
+ "actions": {
71
+ "type": "array",
72
+ "description": (
73
+ "Trigger sequence until state (may be empty). Each "
74
+ "action is an object: do=click|fill|type|press|"
75
+ "select_option|wait_for_selector|wait_for_state|wait "
76
+ "with selector (click/fill/type/press/select_option/"
77
+ "wait_for_selector), value/label (fill/type/select_option), "
78
+ "key (press), state (wait_for_state), ms (wait). "
79
+ "select_option drives a native <select> by option value "
80
+ "(or visible label) and fires change."
81
+ ),
82
+ "items": {"type": "object"},
83
+ },
84
+ "artifact_path": {
85
+ "type": "string",
86
+ "description": (
87
+ "Relative artifact path under the evidence/ subtree of "
88
+ "the configured run root (must already start with "
89
+ "'evidence/'). Provider only writes this file."
90
+ ),
91
+ },
92
+ "overwrite": {
93
+ "type": "boolean",
94
+ "description": (
95
+ "Opt in to replacing an existing artifact. Default "
96
+ "false: an existing file is refused (G6 write boundary)."
97
+ ),
98
+ "default": False,
99
+ },
100
+ },
101
+ "required": ["url", "type", "state", "artifact_path"],
102
+ "additionalProperties": False,
103
+ },
104
+ }
105
+
106
+
107
+ def _failed(
108
+ artifact: str, error: str, written_path: str = "",
109
+ ) -> dict[str, Any]:
110
+ """Capture failure payload.
111
+
112
+ ``written_path`` is the absolute path under the resolved run root when
113
+ known (empty when path resolution never ran). Callers must not treat a
114
+ non-empty path as proof the file exists — only as where the write was
115
+ attempted. Exposing the absolute path makes cwd / RUN_ROOT misconfig
116
+ visible to the orchestrator without a post-hoc filesystem search.
117
+ """
118
+ return {
119
+ "artifact": artifact,
120
+ "observed_state": "unknown",
121
+ "result": "failed",
122
+ "error": error,
123
+ "written_path": written_path,
124
+ }
125
+
126
+
127
+ def _captured(
128
+ artifact: str, observed_state: str, written_path: str,
129
+ ) -> dict[str, Any]:
130
+ """Successful capture payload.
131
+
132
+ ``written_path`` is always the absolute path of the written artifact
133
+ (resolved under DESIGN_PLAYBOOK_RUN_ROOT or process cwd). Relative
134
+ ``artifact`` stays the run-root-relative path for manifest binding.
135
+ """
136
+ return {
137
+ "artifact": artifact,
138
+ "observed_state": observed_state,
139
+ "result": "captured",
140
+ "error": "",
141
+ "written_path": written_path,
142
+ }
143
+
144
+
145
+ def _run_root() -> Path:
146
+ configured = os.environ.get(RUN_ROOT_ENV)
147
+ return Path(configured).resolve() if configured else Path.cwd().resolve()
148
+
149
+
150
+ def _resolve_artifact_path(artifact_path: str) -> Path:
151
+ """Resolve ``artifact_path`` to an absolute path under ``<run_root>/evidence/``.
152
+
153
+ G6 write boundary (issue 04):
154
+ * reject absolute paths (POSIX + Windows + native);
155
+ * reject any ``..`` segment in the requested path (defence in depth
156
+ before resolution — also catches ``evidence/../spec.md``);
157
+ * the resolved candidate must stay under ``<run_root>/evidence/``;
158
+ * explicit ``realpath`` check so a symlink chain that resolves out of
159
+ the evidence subtree is rejected even when ``Path.resolve`` and
160
+ ``os.path.realpath`` disagree across platforms.
161
+
162
+ The caller is responsible for providing a path that already starts with
163
+ ``evidence/``; we do not prepend it (``spec.md`` and ``skills/x`` are
164
+ refused because they land outside the evidence subtree).
165
+ """
166
+ requested = Path(artifact_path)
167
+ if (
168
+ requested.is_absolute()
169
+ or PureWindowsPath(artifact_path).is_absolute()
170
+ or PurePosixPath(artifact_path).is_absolute()
171
+ ):
172
+ raise ValueError("artifact_path must be relative to the configured run root")
173
+
174
+ if any(part == ".." for part in requested.parts):
175
+ raise ValueError("artifact_path must not contain '..' segments")
176
+
177
+ root = _run_root()
178
+ evidence_root = (root / EVIDENCE_SUBDIR).resolve(strict=False)
179
+ candidate = (root / requested).resolve(strict=False)
180
+ try:
181
+ candidate.relative_to(evidence_root)
182
+ except ValueError as exc:
183
+ raise ValueError(
184
+ "artifact_path must stay under the evidence/ subtree"
185
+ ) from exc
186
+
187
+ # Defence in depth: realpath must also stay under evidence/. Catches
188
+ # symlink chains that Path.resolve may normalise differently per platform.
189
+ try:
190
+ Path(os.path.realpath(candidate)).relative_to(
191
+ os.path.realpath(evidence_root)
192
+ )
193
+ except ValueError as exc:
194
+ raise ValueError(
195
+ "artifact_path symlink escapes the evidence/ subtree"
196
+ ) from exc
197
+
198
+ return candidate
199
+
200
+
201
+ def _run_actions(page: Any, actions: list[dict[str, Any]]) -> None:
202
+ for i, action in enumerate(actions):
203
+ if not isinstance(action, dict):
204
+ raise ValueError(f"actions[{i}] must be an object")
205
+ do = action.get("do")
206
+ if not isinstance(do, str) or not do.strip():
207
+ raise ValueError(f"actions[{i}].do is required")
208
+ do = do.strip().lower()
209
+ selector = action.get("selector")
210
+ if do == "click":
211
+ if not isinstance(selector, str) or not selector:
212
+ raise ValueError(f"actions[{i}].selector required for click")
213
+ page.click(selector, timeout=10_000)
214
+ elif do in ("fill", "type"):
215
+ if not isinstance(selector, str) or not selector:
216
+ raise ValueError(f"actions[{i}].selector required for {do}")
217
+ value = action.get("value")
218
+ if value is None:
219
+ value = action.get("text", "")
220
+ if not isinstance(value, str):
221
+ raise ValueError(f"actions[{i}].value must be a string")
222
+ if do == "fill":
223
+ page.fill(selector, value, timeout=10_000)
224
+ else:
225
+ page.click(selector, timeout=10_000)
226
+ page.keyboard.type(value)
227
+ elif do == "press":
228
+ key = action.get("key") or action.get("value")
229
+ if not isinstance(key, str) or not key:
230
+ raise ValueError(f"actions[{i}].key required for press")
231
+ if isinstance(selector, str) and selector:
232
+ page.press(selector, key, timeout=10_000)
233
+ else:
234
+ page.keyboard.press(key)
235
+ elif do == "wait_for_selector":
236
+ if not isinstance(selector, str) or not selector:
237
+ raise ValueError(
238
+ f"actions[{i}].selector required for wait_for_selector"
239
+ )
240
+ page.wait_for_selector(selector, timeout=10_000)
241
+ elif do == "wait_for_state":
242
+ state = action.get("state")
243
+ if not isinstance(state, str) or not state:
244
+ raise ValueError(f"actions[{i}].state required for wait_for_state")
245
+ # Prefer explicit selector; else body[data-state].
246
+ if isinstance(selector, str) and selector:
247
+ page.wait_for_selector(selector, timeout=10_000)
248
+ else:
249
+ page.wait_for_selector(
250
+ f'[data-state="{state}"]',
251
+ timeout=10_000,
252
+ )
253
+ elif do in ("wait", "sleep"):
254
+ ms = action.get("ms")
255
+ if ms is None:
256
+ ms = action.get("timeout_ms", 200)
257
+ page.wait_for_timeout(int(ms))
258
+ elif do == "select_option":
259
+ # Native <select> — page.fill raises "Fill did not work on <select>";
260
+ # select_option drives <option> by value (or visible label) and
261
+ # fires change.
262
+ if not isinstance(selector, str) or not selector:
263
+ raise ValueError(
264
+ f"actions[{i}].selector required for select_option")
265
+ value = action.get("value")
266
+ label = action.get("label")
267
+ if value is None and label is None:
268
+ raise ValueError(
269
+ f"actions[{i}].value or label required for select_option")
270
+ if value is not None:
271
+ page.select_option(selector, value=value, timeout=10_000)
272
+ else:
273
+ page.select_option(selector, label=label, timeout=10_000)
274
+ else:
275
+ raise ValueError(f"actions[{i}]: unsupported do={do!r}")
276
+
277
+
278
+ def _read_observed_state(page: Any) -> str:
279
+ try:
280
+ value = page.evaluate(
281
+ """() => {
282
+ const body = document.body;
283
+ if (body && body.dataset && body.dataset.state) {
284
+ return body.dataset.state;
285
+ }
286
+ const root = document.documentElement;
287
+ if (root && root.dataset && root.dataset.state) {
288
+ return root.dataset.state;
289
+ }
290
+ const el = document.querySelector("[data-state]");
291
+ if (el && el.getAttribute("data-state")) {
292
+ return el.getAttribute("data-state");
293
+ }
294
+ return null;
295
+ }"""
296
+ )
297
+ if isinstance(value, str) and value.strip():
298
+ return value.strip()
299
+ except Exception as exc: # noqa: BLE001 ? report an honest unknown
300
+ _log(f"observed_state probe failed: {exc}")
301
+ return "unknown"
302
+
303
+ def _write_screenshot(page: Any, path: Path) -> None:
304
+ path.parent.mkdir(parents=True, exist_ok=True)
305
+ page.screenshot(path=str(path), full_page=True)
306
+
307
+
308
+ def _write_a11y_tree(page: Any, path: Path) -> None:
309
+ path.parent.mkdir(parents=True, exist_ok=True)
310
+ # Playwright removed page.accessibility; aria_snapshot is the v1 tree.
311
+ if hasattr(page, "aria_snapshot"):
312
+ tree = page.aria_snapshot()
313
+ payload: Any = {"format": "aria_snapshot", "tree": tree}
314
+ elif hasattr(page, "accessibility"):
315
+ payload = page.accessibility.snapshot()
316
+ else:
317
+ raise RuntimeError("page has no aria_snapshot/accessibility API")
318
+ path.write_text(
319
+ json.dumps(payload, ensure_ascii=False, indent=2),
320
+ encoding="utf-8",
321
+ )
322
+
323
+
324
+ def _write_interaction_trace(
325
+ context: Any, page: Any, path: Path, actions: list[dict[str, Any]]
326
+ ) -> None:
327
+ path.parent.mkdir(parents=True, exist_ok=True)
328
+ # Restart tracing for this capture only.
329
+ try:
330
+ context.tracing.stop()
331
+ except Exception: # noqa: BLE001 — may not have started
332
+ pass
333
+ context.tracing.start(screenshots=True, snapshots=True, sources=False)
334
+ try:
335
+ _run_actions(page, actions)
336
+ context.tracing.stop(path=str(path))
337
+ except Exception:
338
+ try:
339
+ context.tracing.stop()
340
+ except Exception: # noqa: BLE001
341
+ pass
342
+ raise
343
+
344
+
345
+ def execute_capture_plan(args: dict[str, Any]) -> dict[str, Any]:
346
+ unknown = sorted(set(args) - ALLOWED_ARGUMENTS)
347
+ if unknown:
348
+ names = ", ".join(unknown)
349
+ raise ValueError(
350
+ f"unsupported argument(s): {names}; provider accepts Runtime Object fields only"
351
+ )
352
+
353
+ url = args.get("url")
354
+ cap_type = args.get("type")
355
+ state = args.get("state")
356
+ actions = args.get("actions")
357
+ artifact_path = args.get("artifact_path")
358
+ overwrite = args.get("overwrite", False)
359
+
360
+ if not isinstance(url, str) or not url.strip():
361
+ raise ValueError("url is required")
362
+ if not isinstance(cap_type, str) or cap_type not in CAPTURE_TYPES:
363
+ raise ValueError(
364
+ f'type must be one of {sorted(CAPTURE_TYPES)}; got {cap_type!r}'
365
+ )
366
+ if not isinstance(state, str) or not state.strip():
367
+ raise ValueError("state is required")
368
+ if not isinstance(artifact_path, str) or not artifact_path.strip():
369
+ raise ValueError("artifact_path is required")
370
+ if not isinstance(overwrite, bool):
371
+ raise ValueError("overwrite must be a boolean")
372
+ if actions is None:
373
+ actions = []
374
+ if not isinstance(actions, list):
375
+ raise ValueError("actions must be an array")
376
+ for i, a in enumerate(actions):
377
+ if not isinstance(a, dict):
378
+ raise ValueError(f"actions[{i}] must be an object")
379
+
380
+ rel = artifact_path.strip()
381
+ try:
382
+ out_path = _resolve_artifact_path(rel)
383
+ except ValueError as exc:
384
+ return _failed(rel, str(exc))
385
+ abs_written = str(out_path)
386
+ # Refuse every case variant of the manifest execution-record SSOT.
387
+ if out_path.name.casefold() == "manifest.jsonl":
388
+ return _failed(rel, "provider never writes manifest.jsonl", abs_written)
389
+ # G6 write boundary: refuse to overwrite an existing artifact unless the
390
+ # caller explicitly opts in via overwrite=true. Checked before any
391
+ # Playwright launch so a misconfigured re-run cannot clobber prior evidence.
392
+ if out_path.exists() and not overwrite:
393
+ return _failed(
394
+ rel,
395
+ f"artifact already exists: {out_path} "
396
+ "(pass overwrite=true to replace)",
397
+ abs_written,
398
+ )
399
+
400
+ try:
401
+ from playwright.sync_api import sync_playwright
402
+ except ImportError as exc:
403
+ return _failed(
404
+ rel,
405
+ f"playwright not installed: {exc}",
406
+ abs_written,
407
+ )
408
+
409
+ try:
410
+ with sync_playwright() as p:
411
+ browser = p.chromium.launch(headless=True)
412
+ try:
413
+ context = browser.new_context(viewport={"width": 1280, "height": 800})
414
+ page = context.new_page()
415
+ page.goto(url.strip(), wait_until="domcontentloaded", timeout=30_000)
416
+
417
+ if cap_type == "interaction trace":
418
+ _write_interaction_trace(context, page, out_path, actions)
419
+ else:
420
+ _run_actions(page, actions)
421
+ if cap_type == "screenshot":
422
+ _write_screenshot(page, out_path)
423
+ elif cap_type == "a11y tree":
424
+ _write_a11y_tree(page, out_path)
425
+
426
+ observed = _read_observed_state(page)
427
+ finally:
428
+ browser.close()
429
+ except Exception as exc: # noqa: BLE001 — surface as capture failure
430
+ _log(f"capture failed: {exc}")
431
+ return _failed(rel, str(exc), abs_written)
432
+
433
+ if not out_path.is_file():
434
+ return _failed(
435
+ rel,
436
+ f"artifact not written: {out_path}",
437
+ abs_written,
438
+ )
439
+
440
+ return _captured(rel, observed, abs_written)
441
+
442
+
443
+ if __name__ == "__main__":
444
+ serve_stdio(
445
+ SERVER_NAME,
446
+ SERVER_VERSION,
447
+ _tool_schema(),
448
+ execute_capture_plan,
449
+ recover_from_malformed=True,
450
+ )