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,255 @@
1
+ """ADR-0008 floor logic + confirm/log records + prototype target resolution.
2
+
3
+ Sibling module split from server.py; behavior unchanged. Holds the
4
+ feedback-floor check, confirm JSON + log writers, prototype path helpers,
5
+ and the ``--self-check`` floor cases.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import secrets
12
+ import sys
13
+ import threading
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from util import _now_iso
18
+
19
+
20
+ def prototype_html_digest(raw: bytes) -> str:
21
+ """SHA-256 of prototype bytes with newlines normalized to LF.
22
+
23
+ Windows ``core.autocrlf`` rewrites working-tree bytes on checkout; a raw
24
+ digest then disagrees between the machine that wrote the confirm record
25
+ and a Linux CI runner validating the same git blob. Line-ending noise is
26
+ not a prototype content change for G5 integrity (issue 02 / T01).
27
+
28
+ Must stay in lockstep with ``scripts/_preview_integrity.prototype_html_digest``.
29
+ """
30
+ return hashlib.sha256(
31
+ raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
32
+ ).hexdigest()
33
+
34
+
35
+ def _preview_dir_for(path: Path | None) -> Path:
36
+ if path is not None:
37
+ return path.parent
38
+ scratch = Path.cwd() / ".scratch" / "preview-adapter" / "preview"
39
+ scratch.mkdir(parents=True, exist_ok=True)
40
+ return scratch
41
+
42
+
43
+
44
+ def _ensure_prototype(path_arg: str | None, html: str | None, round_n: int,
45
+ preview_dir: Path) -> Path:
46
+ if path_arg:
47
+ p = Path(path_arg)
48
+ if not p.is_file():
49
+ raise ValueError(f"prototype path does not exist: {path_arg}")
50
+ return p
51
+ if not html:
52
+ raise ValueError("path or html is required")
53
+ preview_dir.mkdir(parents=True, exist_ok=True)
54
+ target = preview_dir / f"round-{round_n}.html"
55
+ target.write_text(html, encoding="utf-8")
56
+ return target
57
+
58
+
59
+
60
+ def _append_log(preview_dir: Path, *, round_n: int, report_ref: str,
61
+ feedback: str, aborted: bool, selected: list[str],
62
+ anchors: list[dict[str, Any]] | None = None,
63
+ floor_pass: bool | None = None,
64
+ floor_failure: str = "",
65
+ rejected: bool = False,
66
+ rejection: str = "") -> None:
67
+ preview_dir.mkdir(parents=True, exist_ok=True)
68
+ log_path = preview_dir / "log.md"
69
+ if not log_path.is_file():
70
+ log_path.write_text("# preview log\n", encoding="utf-8")
71
+ block = (
72
+ f"\n## round {round_n}\n"
73
+ f"- report_ref: {report_ref}\n"
74
+ f"- timestamp: {_now_iso()}\n"
75
+ f"- feedback: {feedback or ''}\n"
76
+ f"- selected: {', '.join(selected) if selected else ''}\n"
77
+ f"- aborted: {str(aborted).lower()}\n"
78
+ f"- anchors: {len(anchors or [])}\n"
79
+ )
80
+ if floor_pass is not None:
81
+ block += f"- floor_pass: {str(floor_pass).lower()}\n"
82
+ if floor_failure:
83
+ block += f"- floor_failure: {floor_failure}\n"
84
+ # LOW-4 (secure-ship-0.4.4): persist G5 fail-closed rejections (forged
85
+ # token / replay / round mismatch) to log.md so the event is auditable
86
+ # on disk, not just in the ephemeral MCP payload. Only emitted when a
87
+ # decision was actually rejected — a normal confirm/revise/abort leaves
88
+ # no rejection line.
89
+ if rejected:
90
+ block += "- rejected: true\n"
91
+ if rejection:
92
+ block += f"- rejection: {rejection}\n"
93
+ if anchors:
94
+ for i, a in enumerate(anchors, 1):
95
+ sel = a.get("selector") or ""
96
+ note = a.get("comment") or ""
97
+ label = a.get("label") or ""
98
+ block += f" - [{i}] {sel} | {label} | {note}\n"
99
+ with log_path.open("a", encoding="utf-8") as fh:
100
+ fh.write(block)
101
+
102
+
103
+
104
+ def _write_confirm(preview_dir: Path, *, round_n: int, report_ref: str,
105
+ selected: list[str], feedback: str,
106
+ confirmed: bool, floor_pass: bool,
107
+ prototype_html_hash: str,
108
+ floor_failure: str = "") -> Path:
109
+ record = {
110
+ "round": round_n,
111
+ "report_ref": report_ref,
112
+ "confirmed": confirmed,
113
+ "floor_pass": floor_pass,
114
+ "selected_options": selected,
115
+ "feedback": feedback,
116
+ "timestamp": _now_iso(),
117
+ "prototype_path": f"preview/round-{round_n}.html",
118
+ "prototype_html_hash": prototype_html_hash,
119
+ }
120
+ if floor_failure:
121
+ record["floor_failure"] = floor_failure
122
+ out = preview_dir / f"confirm-round-{round_n}.json"
123
+ out.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n",
124
+ encoding="utf-8")
125
+ return out
126
+
127
+
128
+
129
+ def _check_feedback_floor(feedback: str,
130
+ anchors: list[dict[str, Any]]) -> tuple[bool, str]:
131
+ """ADR-0008 preview feedback floor (structural, machine-checkable).
132
+
133
+ Passes when:
134
+ - (non-empty feedback OR >=1 anchor present) as trigger, AND
135
+ - every present anchor (if any) has non-empty selector AND non-empty comment.
136
+
137
+ Deliberately structural, no minimum length: short CJK feedback like
138
+ "太挤了" is substantive; semantic junk (ADR-0008's "安师大" case) is
139
+ ui-evaluator's job (G6), not the floor's.
140
+ Returns (floor_pass, floor_failure_reason).
141
+ """
142
+ feedback = (feedback or "").strip()
143
+ trigger = bool(feedback) or bool(anchors)
144
+ if not trigger:
145
+ return False, "confirm with no substantive feedback: empty feedback and no anchor"
146
+ if anchors:
147
+ for a in anchors:
148
+ if not isinstance(a, dict):
149
+ return False, "anchor is not an object"
150
+ sel = str(a.get("selector") or "").strip()
151
+ note = str(a.get("comment") or "").strip()
152
+ if not sel or not note:
153
+ return False, (
154
+ "anchor missing non-empty selector and comment: "
155
+ f"selector={sel!r} comment={note!r}")
156
+ return True, ""
157
+
158
+
159
+
160
+ def _generate_decision_token() -> str:
161
+ """One-time URL-safe decision token (G5 trust boundary).
162
+
163
+ Proves a POST to /decide originated from the trusted parent control bar
164
+ (which renders the hidden field) rather than from prototype scripts running
165
+ inside the sandboxed iframe, which cannot read the parent DOM. Bound to a
166
+ single preview round via :class:`_DecisionSession`.
167
+ """
168
+ return secrets.token_urlsafe(32)
169
+
170
+
171
+ class _DecisionSession:
172
+ """First-decision-wins token lock for a single preview round (G5).
173
+
174
+ ``validate`` returns ``True`` only for the first POST whose token matches
175
+ (constant-time) AND whose round matches. Every other POST — missing token,
176
+ reused token, mismatched round, or wrong token — is rejected so the caller
177
+ can fail the decision closed. The session grants at most one valid decision.
178
+ """
179
+
180
+ def __init__(self, round_n: int, token: str) -> None:
181
+ self.round_n = round_n
182
+ self._token = token
183
+ self._locked = False
184
+ self._lock = threading.Lock()
185
+ self.last_rejection: str = ""
186
+
187
+ @property
188
+ def locked(self) -> bool:
189
+ return self._locked
190
+
191
+ def validate(self, posted_round: int, posted_token: str | None) -> bool:
192
+ # LOW-1 (secure-ship-0.4.4): the check-then-set on ``_locked`` must
193
+ # be atomic. ThreadingHTTPServer handles each POST on its own
194
+ # thread, so two concurrent valid-token POSTs could both pass the
195
+ # ``if self._locked`` check and each consume the session. Hold the
196
+ # lock for the whole decision so first-decision-wins holds under
197
+ # real concurrency; the lock is uncontended in the single-POST
198
+ # happy path, so the cost is a no-op acquire/release.
199
+ with self._lock:
200
+ if not posted_token:
201
+ self.last_rejection = "missing"
202
+ return False
203
+ if posted_round != self.round_n:
204
+ self.last_rejection = "round_mismatch"
205
+ return False
206
+ if self._locked:
207
+ # First valid decision already consumed the session; every
208
+ # later POST (even with the correct token) is a replay.
209
+ self.last_rejection = "reuse"
210
+ return False
211
+ if not secrets.compare_digest(posted_token, self._token):
212
+ self.last_rejection = "invalid_token"
213
+ return False
214
+ self._locked = True
215
+ self.last_rejection = ""
216
+ return True
217
+
218
+
219
+ def _self_check_floor() -> None:
220
+ """ADR-0008 floor branch logic self-check (ponytail: one runnable check)."""
221
+ cases = [
222
+ ("empty + no anchors", "", [], False),
223
+ ("whitespace-only feedback", " \n ", [], False),
224
+ ("short feedback passes (structural floor)", "ok", [], True),
225
+ ("short CJK feedback passes ('太挤了' is substantive)", "太挤了", [], True),
226
+ ("'安师大' passes floor; semantic junk is G6's job (ADR-0008)", "安师大", [], True),
227
+ ("longer feedback passes", "fix it", [], True),
228
+ ("anchor with comment", "", [{"selector": "h2", "comment": "x"}], True),
229
+ ("anchor no comment (0015 garbage)", "",
230
+ [{"selector": "h2", "comment": ""}], False),
231
+ ("anchor empty selector", "",
232
+ [{"selector": "", "comment": "x"}], False),
233
+ ("non-dict anchor", "", ["not-a-dict"], False),
234
+ ("feedback + incomplete anchor still fails", "ok",
235
+ [{"selector": "h2", "comment": ""}], False),
236
+ ("two anchors one incomplete fails", "",
237
+ [{"selector": "h2", "comment": "x"}, {"selector": "p", "comment": ""}], False),
238
+ ("two anchors both complete passes", "",
239
+ [{"selector": "h2", "comment": "x"}, {"selector": "p", "comment": "y"}], True),
240
+ ("short feedback + good anchor passes", "hi",
241
+ [{"selector": "h2", "comment": "x"}], True),
242
+ ]
243
+ for label, fb, anc, want in cases:
244
+ got, _ = _check_feedback_floor(fb, anc)
245
+ assert got == want, f"{label}: want {want}, got {got}"
246
+ print("FLOOR SELF-CHECK PASSED")
247
+
248
+
249
+ if __name__ == "__main__":
250
+ if len(sys.argv) > 1 and sys.argv[1] == "--self-check":
251
+ _self_check_floor()
252
+ else:
253
+ raise SystemExit(
254
+ "usage: confirm.py --self-check (the MCP server entry is server.py)")
255
+