design-playbook 0.8.0 → 0.9.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.
@@ -12,6 +12,7 @@ import html
12
12
  import json
13
13
  import os
14
14
  import re
15
+ import secrets
15
16
  import shutil
16
17
  import subprocess
17
18
  import sys
@@ -23,14 +24,68 @@ from pathlib import Path
23
24
  from typing import Any
24
25
  from urllib.parse import parse_qs
25
26
 
26
- from confirm import (
27
- _DecisionSession,
28
- _generate_decision_token,
29
- prototype_html_digest,
30
- )
31
27
  from control import _build_control
32
28
  from i18n import lang, t
33
- from util import _log
29
+ from util import _log, prototype_html_digest
30
+
31
+
32
+ def _generate_decision_token() -> str:
33
+ """One-time URL-safe decision token (G5 trust boundary).
34
+
35
+ Proves a POST to /decide originated from the trusted parent control bar
36
+ (which renders the hidden field) rather than from prototype scripts running
37
+ inside the sandboxed iframe, which cannot read the parent DOM. Bound to a
38
+ single preview round via :class:`_DecisionSession`.
39
+ """
40
+ return secrets.token_urlsafe(32)
41
+
42
+
43
+ class _DecisionSession:
44
+ """First-decision-wins token lock for a single preview round (G5).
45
+
46
+ ``validate`` returns ``True`` only for the first POST whose token matches
47
+ (constant-time) AND whose round matches. Every other POST — missing token,
48
+ reused token, mismatched round, or wrong token — is rejected so the caller
49
+ can fail the decision closed. The session grants at most one valid decision.
50
+ """
51
+
52
+ def __init__(self, round_n: int, token: str) -> None:
53
+ self.round_n = round_n
54
+ self._token = token
55
+ self._locked = False
56
+ self._lock = threading.Lock()
57
+ self.last_rejection: str = ""
58
+
59
+ @property
60
+ def locked(self) -> bool:
61
+ return self._locked
62
+
63
+ def validate(self, posted_round: int, posted_token: str | None) -> bool:
64
+ # LOW-1 (secure-ship-0.4.4): the check-then-set on ``_locked`` must
65
+ # be atomic. ThreadingHTTPServer handles each POST on its own
66
+ # thread, so two concurrent valid-token POSTs could both pass the
67
+ # ``if self._locked`` check and each consume the session. Hold the
68
+ # lock for the whole decision so first-decision-wins holds under
69
+ # real concurrency; the lock is uncontended in the single-POST
70
+ # happy path, so the cost is a no-op acquire/release.
71
+ with self._lock:
72
+ if not posted_token:
73
+ self.last_rejection = "missing"
74
+ return False
75
+ if posted_round != self.round_n:
76
+ self.last_rejection = "round_mismatch"
77
+ return False
78
+ if self._locked:
79
+ # First valid decision already consumed the session; every
80
+ # later POST (even with the correct token) is a replay.
81
+ self.last_rejection = "reuse"
82
+ return False
83
+ if not secrets.compare_digest(posted_token, self._token):
84
+ self.last_rejection = "invalid_token"
85
+ return False
86
+ self._locked = True
87
+ self.last_rejection = ""
88
+ return True
34
89
 
35
90
 
36
91
  def _screen_size() -> tuple[int, int]:
@@ -24,9 +24,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
24
24
  from _transport import ToolError, serve_stdio # noqa: E402
25
25
 
26
26
  import browser
27
- from confirm import _self_check_floor
28
27
  from i18n import default_options
29
- from transaction import PreviewTransactionError, run_preview_transaction
28
+ from transaction import (
29
+ PreviewTransactionError,
30
+ _self_check_floor,
31
+ run_preview_transaction,
32
+ )
30
33
 
31
34
  TOOL_NAME = "preview_prototype"
32
35
 
@@ -33,11 +33,11 @@ from urllib.parse import urlencode
33
33
  sys.path.insert(0, str(Path(__file__).resolve().parent))
34
34
  import browser # noqa: E402
35
35
  import control as preview_control # noqa: E402
36
- from confirm import ( # noqa: E402
36
+ from browser import ( # noqa: E402
37
37
  _DecisionSession,
38
38
  _generate_decision_token,
39
- prototype_html_digest,
40
39
  )
40
+ from util import prototype_html_digest # noqa: E402
41
41
 
42
42
 
43
43
  # --------------------------------------------------------------------------- #
@@ -17,20 +17,97 @@ from contextlib import contextmanager
17
17
  from pathlib import Path
18
18
  from typing import Any, Callable, Iterator
19
19
 
20
- from confirm import (
21
- _check_feedback_floor,
22
- _ensure_prototype,
23
- _preview_dir_for,
24
- prototype_html_digest,
25
- )
26
20
  from control import _format_feedback
27
21
  from i18n import CONFIRM_LABELS
28
- from util import _now_iso
22
+ from util import _now_iso, prototype_html_digest
29
23
 
30
24
  BrowserCollector = Callable[[Path, str, list[str], int], dict[str, Any]]
31
25
  ENTRY_SCHEMA_VERSION = 1
32
26
 
33
27
 
28
+ def _preview_dir_for(path: Path | None) -> Path:
29
+ if path is not None:
30
+ return path.parent
31
+ scratch = Path.cwd() / ".scratch" / "preview-adapter" / "preview"
32
+ scratch.mkdir(parents=True, exist_ok=True)
33
+ return scratch
34
+
35
+
36
+ def _ensure_prototype(path_arg: str | None, html: str | None, round_n: int,
37
+ preview_dir: Path) -> Path:
38
+ if path_arg:
39
+ p = Path(path_arg)
40
+ if not p.is_file():
41
+ raise ValueError(f"prototype path does not exist: {path_arg}")
42
+ return p
43
+ if not html:
44
+ raise ValueError("path or html is required")
45
+ preview_dir.mkdir(parents=True, exist_ok=True)
46
+ target = preview_dir / f"round-{round_n}.html"
47
+ target.write_text(html, encoding="utf-8")
48
+ return target
49
+
50
+
51
+ def _check_feedback_floor(feedback: str,
52
+ anchors: list[dict[str, Any]]) -> tuple[bool, str]:
53
+ """ADR-0008 preview feedback floor (structural, machine-checkable).
54
+
55
+ Passes when:
56
+ - (non-empty feedback OR >=1 anchor present) as trigger, AND
57
+ - every present anchor (if any) has non-empty selector AND non-empty comment.
58
+
59
+ Deliberately structural, no minimum length: short CJK feedback like
60
+ "太挤了" is substantive; semantic junk (ADR-0008's "安师大" case) is
61
+ ui-evaluator's job (G6), not the floor's.
62
+ Returns (floor_pass, floor_failure_reason).
63
+ """
64
+ feedback = (feedback or "").strip()
65
+ trigger = bool(feedback) or bool(anchors)
66
+ if not trigger:
67
+ return False, "confirm with no substantive feedback: empty feedback and no anchor"
68
+ if anchors:
69
+ for a in anchors:
70
+ if not isinstance(a, dict):
71
+ return False, "anchor is not an object"
72
+ sel = str(a.get("selector") or "").strip()
73
+ note = str(a.get("comment") or "").strip()
74
+ if not sel or not note:
75
+ return False, (
76
+ "anchor missing non-empty selector and comment: "
77
+ f"selector={sel!r} comment={note!r}")
78
+ return True, ""
79
+
80
+
81
+ def _self_check_floor() -> None:
82
+ """ADR-0008 floor branch logic self-check (ponytail: one runnable check)."""
83
+ cases = [
84
+ ("empty + no anchors", "", [], False),
85
+ ("whitespace-only feedback", " \n ", [], False),
86
+ ("short feedback passes (structural floor)", "ok", [], True),
87
+ ("short CJK feedback passes ('太挤了' is substantive)", "太挤了", [], True),
88
+ ("'安师大' passes floor; semantic junk is G6's job (ADR-0008)", "安师大", [], True),
89
+ ("longer feedback passes", "fix it", [], True),
90
+ ("anchor with comment", "", [{"selector": "h2", "comment": "x"}], True),
91
+ ("anchor no comment (0015 garbage)", "",
92
+ [{"selector": "h2", "comment": ""}], False),
93
+ ("anchor empty selector", "",
94
+ [{"selector": "", "comment": "x"}], False),
95
+ ("non-dict anchor", "", ["not-a-dict"], False),
96
+ ("feedback + incomplete anchor still fails", "ok",
97
+ [{"selector": "h2", "comment": ""}], False),
98
+ ("two anchors one incomplete fails", "",
99
+ [{"selector": "h2", "comment": "x"}, {"selector": "p", "comment": ""}], False),
100
+ ("two anchors both complete passes", "",
101
+ [{"selector": "h2", "comment": "x"}, {"selector": "p", "comment": "y"}], True),
102
+ ("short feedback + good anchor passes", "hi",
103
+ [{"selector": "h2", "comment": "x"}], True),
104
+ ]
105
+ for label, fb, anc, want in cases:
106
+ got, _ = _check_feedback_floor(fb, anc)
107
+ assert got == want, f"{label}: want {want}, got {got}"
108
+ print("FLOOR SELF-CHECK PASSED")
109
+
110
+
34
111
  class PreviewTransactionError(ValueError):
35
112
  """Recoverable transaction failure with actionable artifact context."""
36
113
 
@@ -1,10 +1,11 @@
1
- """Shared leaf helpers for the preview adapter (logging, timestamps).
1
+ """Shared leaf helpers for the preview adapter (logging, timestamps, digest).
2
2
 
3
- Sibling to i18n.py; imported by server.py, browser.py, confirm.py.
3
+ Sibling to i18n.py; imported by server.py, browser.py, transaction.py.
4
4
  No third-party deps.
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
+ import hashlib
8
9
  import sys
9
10
  from datetime import datetime, timezone
10
11
 
@@ -13,6 +14,21 @@ def _log(msg: str) -> None:
13
14
  print(msg, file=sys.stderr, flush=True)
14
15
 
15
16
 
17
+ def prototype_html_digest(raw: bytes) -> str:
18
+ """SHA-256 of prototype bytes with newlines normalized to LF.
19
+
20
+ Windows ``core.autocrlf`` rewrites working-tree bytes on checkout; a raw
21
+ digest then disagrees between the machine that wrote the confirm record
22
+ and a Linux CI runner validating the same git blob. Line-ending noise is
23
+ not a prototype content change for G5 integrity (issue 02 / T01).
24
+
25
+ Must stay in lockstep with ``scripts/_preview_integrity.prototype_html_digest``.
26
+ """
27
+ return hashlib.sha256(
28
+ raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
29
+ ).hexdigest()
30
+
31
+
16
32
 
17
33
  def _now_iso() -> str:
18
34
  return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "design-playbook",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
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
5
  "keywords": [
6
6
  "pi-package",
@@ -1,183 +0,0 @@
1
- """ADR-0008 floor logic, G5 trust token, and prototype target resolution.
2
-
3
- Sibling module split from server.py; behavior unchanged. Holds the
4
- feedback-floor check, the G5 one-time decision token + first-decision-wins
5
- session, the prototype html digest primitive, and the ``--self-check``
6
- floor cases.
7
- """
8
- from __future__ import annotations
9
-
10
- import hashlib
11
- import secrets
12
- import sys
13
- import threading
14
- from pathlib import Path
15
- from typing import Any
16
-
17
-
18
- def prototype_html_digest(raw: bytes) -> str:
19
- """SHA-256 of prototype bytes with newlines normalized to LF.
20
-
21
- Windows ``core.autocrlf`` rewrites working-tree bytes on checkout; a raw
22
- digest then disagrees between the machine that wrote the confirm record
23
- and a Linux CI runner validating the same git blob. Line-ending noise is
24
- not a prototype content change for G5 integrity (issue 02 / T01).
25
-
26
- Must stay in lockstep with ``scripts/_preview_integrity.prototype_html_digest``.
27
- """
28
- return hashlib.sha256(
29
- raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
30
- ).hexdigest()
31
-
32
-
33
- def _preview_dir_for(path: Path | None) -> Path:
34
- if path is not None:
35
- return path.parent
36
- scratch = Path.cwd() / ".scratch" / "preview-adapter" / "preview"
37
- scratch.mkdir(parents=True, exist_ok=True)
38
- return scratch
39
-
40
-
41
-
42
- def _ensure_prototype(path_arg: str | None, html: str | None, round_n: int,
43
- preview_dir: Path) -> Path:
44
- if path_arg:
45
- p = Path(path_arg)
46
- if not p.is_file():
47
- raise ValueError(f"prototype path does not exist: {path_arg}")
48
- return p
49
- if not html:
50
- raise ValueError("path or html is required")
51
- preview_dir.mkdir(parents=True, exist_ok=True)
52
- target = preview_dir / f"round-{round_n}.html"
53
- target.write_text(html, encoding="utf-8")
54
- return target
55
-
56
-
57
- def _check_feedback_floor(feedback: str,
58
- anchors: list[dict[str, Any]]) -> tuple[bool, str]:
59
- """ADR-0008 preview feedback floor (structural, machine-checkable).
60
-
61
- Passes when:
62
- - (non-empty feedback OR >=1 anchor present) as trigger, AND
63
- - every present anchor (if any) has non-empty selector AND non-empty comment.
64
-
65
- Deliberately structural, no minimum length: short CJK feedback like
66
- "太挤了" is substantive; semantic junk (ADR-0008's "安师大" case) is
67
- ui-evaluator's job (G6), not the floor's.
68
- Returns (floor_pass, floor_failure_reason).
69
- """
70
- feedback = (feedback or "").strip()
71
- trigger = bool(feedback) or bool(anchors)
72
- if not trigger:
73
- return False, "confirm with no substantive feedback: empty feedback and no anchor"
74
- if anchors:
75
- for a in anchors:
76
- if not isinstance(a, dict):
77
- return False, "anchor is not an object"
78
- sel = str(a.get("selector") or "").strip()
79
- note = str(a.get("comment") or "").strip()
80
- if not sel or not note:
81
- return False, (
82
- "anchor missing non-empty selector and comment: "
83
- f"selector={sel!r} comment={note!r}")
84
- return True, ""
85
-
86
-
87
-
88
- def _generate_decision_token() -> str:
89
- """One-time URL-safe decision token (G5 trust boundary).
90
-
91
- Proves a POST to /decide originated from the trusted parent control bar
92
- (which renders the hidden field) rather than from prototype scripts running
93
- inside the sandboxed iframe, which cannot read the parent DOM. Bound to a
94
- single preview round via :class:`_DecisionSession`.
95
- """
96
- return secrets.token_urlsafe(32)
97
-
98
-
99
- class _DecisionSession:
100
- """First-decision-wins token lock for a single preview round (G5).
101
-
102
- ``validate`` returns ``True`` only for the first POST whose token matches
103
- (constant-time) AND whose round matches. Every other POST — missing token,
104
- reused token, mismatched round, or wrong token — is rejected so the caller
105
- can fail the decision closed. The session grants at most one valid decision.
106
- """
107
-
108
- def __init__(self, round_n: int, token: str) -> None:
109
- self.round_n = round_n
110
- self._token = token
111
- self._locked = False
112
- self._lock = threading.Lock()
113
- self.last_rejection: str = ""
114
-
115
- @property
116
- def locked(self) -> bool:
117
- return self._locked
118
-
119
- def validate(self, posted_round: int, posted_token: str | None) -> bool:
120
- # LOW-1 (secure-ship-0.4.4): the check-then-set on ``_locked`` must
121
- # be atomic. ThreadingHTTPServer handles each POST on its own
122
- # thread, so two concurrent valid-token POSTs could both pass the
123
- # ``if self._locked`` check and each consume the session. Hold the
124
- # lock for the whole decision so first-decision-wins holds under
125
- # real concurrency; the lock is uncontended in the single-POST
126
- # happy path, so the cost is a no-op acquire/release.
127
- with self._lock:
128
- if not posted_token:
129
- self.last_rejection = "missing"
130
- return False
131
- if posted_round != self.round_n:
132
- self.last_rejection = "round_mismatch"
133
- return False
134
- if self._locked:
135
- # First valid decision already consumed the session; every
136
- # later POST (even with the correct token) is a replay.
137
- self.last_rejection = "reuse"
138
- return False
139
- if not secrets.compare_digest(posted_token, self._token):
140
- self.last_rejection = "invalid_token"
141
- return False
142
- self._locked = True
143
- self.last_rejection = ""
144
- return True
145
-
146
-
147
- def _self_check_floor() -> None:
148
- """ADR-0008 floor branch logic self-check (ponytail: one runnable check)."""
149
- cases = [
150
- ("empty + no anchors", "", [], False),
151
- ("whitespace-only feedback", " \n ", [], False),
152
- ("short feedback passes (structural floor)", "ok", [], True),
153
- ("short CJK feedback passes ('太挤了' is substantive)", "太挤了", [], True),
154
- ("'安师大' passes floor; semantic junk is G6's job (ADR-0008)", "安师大", [], True),
155
- ("longer feedback passes", "fix it", [], True),
156
- ("anchor with comment", "", [{"selector": "h2", "comment": "x"}], True),
157
- ("anchor no comment (0015 garbage)", "",
158
- [{"selector": "h2", "comment": ""}], False),
159
- ("anchor empty selector", "",
160
- [{"selector": "", "comment": "x"}], False),
161
- ("non-dict anchor", "", ["not-a-dict"], False),
162
- ("feedback + incomplete anchor still fails", "ok",
163
- [{"selector": "h2", "comment": ""}], False),
164
- ("two anchors one incomplete fails", "",
165
- [{"selector": "h2", "comment": "x"}, {"selector": "p", "comment": ""}], False),
166
- ("two anchors both complete passes", "",
167
- [{"selector": "h2", "comment": "x"}, {"selector": "p", "comment": "y"}], True),
168
- ("short feedback + good anchor passes", "hi",
169
- [{"selector": "h2", "comment": "x"}], True),
170
- ]
171
- for label, fb, anc, want in cases:
172
- got, _ = _check_feedback_floor(fb, anc)
173
- assert got == want, f"{label}: want {want}, got {got}"
174
- print("FLOOR SELF-CHECK PASSED")
175
-
176
-
177
- if __name__ == "__main__":
178
- if len(sys.argv) > 1 and sys.argv[1] == "--self-check":
179
- _self_check_floor()
180
- else:
181
- raise SystemExit(
182
- "usage: confirm.py --self-check (the MCP server entry is server.py)")
183
-