design-playbook 0.13.0 → 0.14.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/codex/AGENTS.md CHANGED
@@ -36,10 +36,14 @@ python packages/design-playbook/codex/install_skills.py --force
36
36
  # @packages/design-playbook/skills/design-playbook/SKILL.md
37
37
  ```
38
38
 
39
- Manual MCP (only if not using `codex plugin add`):
39
+ Register MCP directly (fallback when `codex plugin add` is unavailable):
40
+
41
+ `codex plugin add` depends on a healthy codex marketplace subsystem. If `codex doctor`
42
+ or `codex plugin marketplace list` fails (e.g. a stale marketplace source path), register
43
+ the servers directly in `~/.codex/config.toml` (or your `CODEX_HOME`):
40
44
 
41
45
  ```toml
42
- # ~/.codex/config.toml
46
+ # ~/.codex/config.toml (or $CODEX_HOME/config.toml)
43
47
  [mcp_servers.design-playbook-preview]
44
48
  command = "python"
45
49
  args = ["<abs>/packages/design-playbook/mcp/preview/server.py"]
@@ -50,6 +54,16 @@ args = ["<abs>/packages/design-playbook/mcp/evidence/server.py"]
50
54
  # evidence also needs: pip install playwright && playwright install chromium
51
55
  ```
52
56
 
57
+ Verify: `codex mcp list` should list both. `preview*` needs a system Edge/Chrome (the
58
+ adapter spawns it via `--app=`); `observe*` needs Playwright + Chromium.
59
+
60
+ > **`preview*` silently skips when `preview_prototype` is absent.** If preview does not
61
+ > appear, the orchestrator probed `tools/list`, found no `preview_prototype`, and skipped
62
+ > G5 - this is designed skip behaviour, not a crash. Confirm the tool is registered
63
+ > (`codex mcp list`) before treating it as a preview failure. Codex end-to-end preview
64
+ > smoke is not yet validated (v0.4.4 deferred the codex E2E smoke; only evidence/G6 was
65
+ > server-level smoked).
66
+
53
67
  ## Load order
54
68
 
55
69
  1. `skills/design-playbook/SKILL.md`
package/lib/index.js ADDED
@@ -0,0 +1,172 @@
1
+ 'use strict'
2
+ /**
3
+ * design-playbook dsh plugin
4
+ *
5
+ * Registers two contributions on the DSH context:
6
+ *
7
+ * 1. A skill provider (ctx.skills) whose candidates are this package's
8
+ * `skills/` directory. The plugin locates the directory via `__dirname`,
9
+ * so no `!!js` expression and no cwd-dependent resolution is involved.
10
+ *
11
+ * 2. Six slash commands (ctx.commands) — `design-io`, `doctor`,
12
+ * `run-review`, `run-status`, `ui-review`, `ux-spec` — that load the
13
+ * matching `commands/<name>.md` prompt, substitute `$ARGUMENTS` with the
14
+ * raw trailing input, and inject it as a user-role follow-up turn via
15
+ * `agent.followup()`.
16
+ *
17
+ * The Cordis `!!js` evaluation scope provides no `require` (only Node globals
18
+ * plus ctx-provided values like dshHomePath/loader), so pointing a
19
+ * skill-filesystem customSkillDirs row at package resources via
20
+ * `require.resolve` does not work. The plugin route is the supported way for
21
+ * a package to contribute its own skills and commands.
22
+ *
23
+ * Requires `ctx.skills` (the skill registry from @deepseek-ai/dsh-skill),
24
+ * `ctx.commands` (from @deepseek-ai/dsh-commands), and `@deepseek-ai/dsh-llm`
25
+ * (a core DSH dependency, always present in a booted profile).
26
+ */
27
+
28
+ const fs = require('node:fs')
29
+ const path = require('node:path')
30
+
31
+ const SKILLS_DIR = path.join(__dirname, '..', 'skills')
32
+ const COMMANDS_DIR = path.join(__dirname, '..', 'commands')
33
+
34
+ /** Parse a minimal frontmatter block: `---\nkey: value\n...\n---\nbody`. */
35
+ function parseSkillFile(filePath) {
36
+ const text = fs.readFileSync(filePath, 'utf8')
37
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
38
+ if (!m) return null
39
+ const front = {}
40
+ for (const line of m[1].split(/\r?\n/)) {
41
+ const kv = /^([a-zA-Z0-9-]+):\s*(.*)$/.exec(line)
42
+ if (kv) front[kv[1]] = kv[2]
43
+ }
44
+ return { meta: front, content: m[2].trim() }
45
+ }
46
+
47
+ /**
48
+ * Build a user-role message carrying one text block.
49
+ *
50
+ * @deepseek-ai/dsh-llm is a core DSH dependency, but it lives in the DSH
51
+ * installation's node_modules, not in this package's node_modules.
52
+ * require.resolve from this package may fail depending on hoisting; the
53
+ * fallback plain object has the same shape DSH's inbox reads
54
+ * ({ id, role, content, source }) and is safe in all runtimes.
55
+ * @param {string} text - the prepared prompt body.
56
+ * @returns {object} a user-role message.
57
+ */
58
+ function createUserMessageFromPrompt(text) {
59
+ try {
60
+ const { createUserMessage } = require('@deepseek-ai/dsh-llm')
61
+ return createUserMessage({
62
+ content: [{ type: 'text', text }],
63
+ source: { kind: 'user' },
64
+ })
65
+ } catch {
66
+ const crypto = require('node:crypto')
67
+ return Object.freeze({
68
+ id: crypto.randomUUID(),
69
+ role: 'user',
70
+ content: [{ type: 'text', text }],
71
+ source: { kind: 'user' },
72
+ })
73
+ }
74
+ }
75
+
76
+ exports.name = 'design-playbook'
77
+ exports.inject = ['skills', 'commands']
78
+
79
+ // Exported for test_commands.js to avoid duplicating the helpers.
80
+ exports.parseSkillFile = parseSkillFile
81
+ exports.createUserMessageFromPrompt = createUserMessageFromPrompt
82
+
83
+ /**
84
+ * The six slash commands this plugin registers. Each maps to a
85
+ * `commands/<name>.md` prompt file; the file's frontmatter `description`
86
+ * becomes the command's discovery metadata, and the body (with `$ARGUMENTS`
87
+ * substituted) is injected as a user follow-up turn.
88
+ */
89
+ const COMMAND_NAMES = [
90
+ 'design-io',
91
+ 'doctor',
92
+ 'run-review',
93
+ 'run-status',
94
+ 'ui-review',
95
+ 'ux-spec',
96
+ ]
97
+
98
+ exports.apply = function (ctx) {
99
+ // ---- skills provider (P1) ----
100
+ if (fs.existsSync(SKILLS_DIR)) {
101
+ ctx.skills.registerProvider(() => {
102
+ const candidates = fs
103
+ .readdirSync(SKILLS_DIR, { withFileTypes: true })
104
+ .filter((e) => e.isDirectory())
105
+ .map((e) => {
106
+ const skillPath = path.join(SKILLS_DIR, e.name, 'SKILL.md')
107
+ if (!fs.existsSync(skillPath)) return null
108
+ const parsed = parseSkillFile(skillPath)
109
+ if (!parsed) return null
110
+ const meta = parsed.meta
111
+ const name = meta.name
112
+ const description = meta.description
113
+ if (!name || !description) return null
114
+ return {
115
+ name,
116
+ description,
117
+ ...(meta.whenToUse ? { whenToUse: meta.whenToUse } : {}),
118
+ rank: 600, // bundled rank (matches skill-filesystem's bundled rank)
119
+ invocation: { modelInvocable: true, userInvocable: true },
120
+ source: 'bundled',
121
+ provider: 'design-playbook',
122
+ resourceBase: { kind: 'directory', path: path.join(SKILLS_DIR, e.name) },
123
+ locator: skillPath,
124
+ path: skillPath,
125
+ }
126
+ })
127
+ .filter(Boolean)
128
+
129
+ return {
130
+ name: 'design-playbook',
131
+ list: async () => candidates,
132
+ get: async (candidate) => {
133
+ const parsed = parseSkillFile(candidate.locator)
134
+ if (!parsed) return undefined
135
+ return { ...candidate, content: parsed.content }
136
+ },
137
+ }
138
+ })
139
+ }
140
+
141
+ // ---- commands (P2) ----
142
+ // Each command loads its prompt from commands/<name>.md, substitutes
143
+ // $ARGUMENTS, and injects the result as a user-role follow-up turn. The
144
+ // handler returns a CommandResult immediately — the actual model work
145
+ // happens in the turn opened by agent.followup().
146
+ if (ctx.commands && fs.existsSync(COMMANDS_DIR)) {
147
+ for (const name of COMMAND_NAMES) {
148
+ const filePath = path.join(COMMANDS_DIR, `${name}.md`)
149
+ if (!fs.existsSync(filePath)) continue
150
+ const parsed = parseSkillFile(filePath)
151
+ if (!parsed || !parsed.meta.description) continue
152
+
153
+ // Capture the parsed body once — the handler substitutes $ARGUMENTS
154
+ // on the already-parsed content instead of re-reading the file.
155
+ const promptBody = parsed.content
156
+ ctx.commands.register({
157
+ name,
158
+ description: parsed.meta.description,
159
+ handler: (invocation) => {
160
+ const prompt = promptBody.replace(/\$ARGUMENTS/g, invocation.rawInput)
161
+ const message = createUserMessageFromPrompt(prompt)
162
+ // followup() queues an ordinary turn and wakes the driver — a
163
+ // slash command is an explicit user action, so followup is the
164
+ // right boundary (not inject(), which seeds context without
165
+ // waking).
166
+ invocation.agent.followup(message)
167
+ return { kind: 'success' }
168
+ },
169
+ })
170
+ }
171
+ }
172
+ }
@@ -15,6 +15,7 @@ from typing import Any, Protocol
15
15
 
16
16
  from design_playbook.mcp.evidence import containment
17
17
  from design_playbook.mcp.evidence.capture_contract import parse_capture_contract
18
+ from design_playbook.mcp.preview.util import _log
18
19
 
19
20
  CAPTURE_TYPES = frozenset({"screenshot", "a11y tree", "interaction trace"})
20
21
  ALLOWED_ARGUMENTS = frozenset(
@@ -33,10 +34,6 @@ ALLOWED_ARGUMENTS = frozenset(
33
34
  RUN_ROOT_ENV = "DESIGN_PLAYBOOK_RUN_ROOT"
34
35
 
35
36
 
36
- def _log(msg: str) -> None:
37
- print(msg, file=sys.stderr, flush=True)
38
-
39
-
40
37
  class BrowserAdapter(Protocol):
41
38
  """Internal browser seam used by the capture runtime."""
42
39
 
@@ -167,7 +164,8 @@ def _resolve_artifact_path(artifact_path: str) -> Path:
167
164
  """
168
165
  result = containment.write_target(artifact_path, _run_root())
169
166
  if result.ok:
170
- return result.path # type: ignore[return-value]
167
+ assert result.path is not None # ok implies path is set
168
+ return result.path
171
169
  raise ValueError(_reason_message(result.reason))
172
170
 
173
171
 
@@ -203,6 +201,106 @@ _REASON_MESSAGES = {
203
201
  }
204
202
 
205
203
 
204
+ def _require_selector(action: dict, index: int, do: str) -> str:
205
+ """Extract and validate a required selector for an action."""
206
+ selector = action.get("selector")
207
+ if not isinstance(selector, str) or not selector:
208
+ raise ValueError(f"actions[{index}].selector required for {do}")
209
+ return selector
210
+
211
+
212
+ def _action_click(page: Any, action: dict, index: int, do: str) -> None:
213
+ selector = _require_selector(action, index, do)
214
+ page.click(selector, timeout=10_000)
215
+
216
+
217
+ def _action_fill(page: Any, action: dict, index: int, do: str) -> None:
218
+ selector = _require_selector(action, index, do)
219
+ value = action.get("value")
220
+ if value is None:
221
+ value = action.get("text", "")
222
+ if not isinstance(value, str):
223
+ raise ValueError(f"actions[{index}].value must be a string")
224
+ page.fill(selector, value, timeout=10_000)
225
+
226
+
227
+ def _action_type(page: Any, action: dict, index: int, do: str) -> None:
228
+ selector = _require_selector(action, index, do)
229
+ value = action.get("value")
230
+ if value is None:
231
+ value = action.get("text", "")
232
+ if not isinstance(value, str):
233
+ raise ValueError(f"actions[{index}].value must be a string")
234
+ page.click(selector, timeout=10_000)
235
+ page.keyboard.type(value)
236
+
237
+
238
+ def _action_press(page: Any, action: dict, index: int, do: str) -> None:
239
+ key = action.get("key") or action.get("value")
240
+ if not isinstance(key, str) or not key:
241
+ raise ValueError(f"actions[{index}].key required for press")
242
+ selector = action.get("selector")
243
+ if isinstance(selector, str) and selector:
244
+ page.press(selector, key, timeout=10_000)
245
+ else:
246
+ page.keyboard.press(key)
247
+
248
+
249
+ def _action_wait_for_selector(page: Any, action: dict, index: int, do: str) -> None:
250
+ selector = _require_selector(action, index, do)
251
+ page.wait_for_selector(selector, timeout=10_000)
252
+
253
+
254
+ def _action_wait_for_state(page: Any, action: dict, index: int, do: str) -> None:
255
+ state = action.get("state")
256
+ if not isinstance(state, str) or not state:
257
+ raise ValueError(f"actions[{index}].state required for wait_for_state")
258
+ selector = action.get("selector")
259
+ if isinstance(selector, str) and selector:
260
+ page.wait_for_selector(selector, timeout=10_000)
261
+ else:
262
+ page.wait_for_selector(
263
+ f'[data-state="{state}"]',
264
+ timeout=10_000,
265
+ )
266
+
267
+
268
+ def _action_wait(page: Any, action: dict, index: int, do: str) -> None:
269
+ ms = action.get("ms")
270
+ if ms is None:
271
+ ms = action.get("timeout_ms", 200)
272
+ page.wait_for_timeout(int(ms))
273
+
274
+
275
+ def _action_select_option(page: Any, action: dict, index: int, do: str) -> None:
276
+ selector = _require_selector(action, index, do)
277
+ value = action.get("value")
278
+ label = action.get("label")
279
+ if value is None and label is None:
280
+ raise ValueError(
281
+ f"actions[{index}].value or label required for select_option")
282
+ if value is not None:
283
+ page.select_option(selector, value=value, timeout=10_000)
284
+ else:
285
+ page.select_option(selector, label=label, timeout=10_000)
286
+
287
+
288
+ # Action registry: do → handler. Each handler owns its validation + Playwright
289
+ # call. Adding an action type means adding a function + one entry here, not an
290
+ # elif branch in a 75-line function.
291
+ _ACTION_HANDLERS: dict[str, Any] = {
292
+ "click": _action_click,
293
+ "fill": _action_fill,
294
+ "type": _action_type,
295
+ "press": _action_press,
296
+ "wait_for_selector": _action_wait_for_selector,
297
+ "wait_for_state": _action_wait_for_state,
298
+ "wait": _action_wait,
299
+ "sleep": _action_wait,
300
+ "select_option": _action_select_option,
301
+ }
302
+
303
+
206
304
  def _run_actions(page: Any, actions: list[dict[str, Any]]) -> None:
207
305
  for i, action in enumerate(actions):
208
306
  if not isinstance(action, dict):
@@ -211,73 +309,10 @@ def _run_actions(page: Any, actions: list[dict[str, Any]]) -> None:
211
309
  if not isinstance(do, str) or not do.strip():
212
310
  raise ValueError(f"actions[{i}].do is required")
213
311
  do = do.strip().lower()
214
- selector = action.get("selector")
215
- if do == "click":
216
- if not isinstance(selector, str) or not selector:
217
- raise ValueError(f"actions[{i}].selector required for click")
218
- page.click(selector, timeout=10_000)
219
- elif do in ("fill", "type"):
220
- if not isinstance(selector, str) or not selector:
221
- raise ValueError(f"actions[{i}].selector required for {do}")
222
- value = action.get("value")
223
- if value is None:
224
- value = action.get("text", "")
225
- if not isinstance(value, str):
226
- raise ValueError(f"actions[{i}].value must be a string")
227
- if do == "fill":
228
- page.fill(selector, value, timeout=10_000)
229
- else:
230
- page.click(selector, timeout=10_000)
231
- page.keyboard.type(value)
232
- elif do == "press":
233
- key = action.get("key") or action.get("value")
234
- if not isinstance(key, str) or not key:
235
- raise ValueError(f"actions[{i}].key required for press")
236
- if isinstance(selector, str) and selector:
237
- page.press(selector, key, timeout=10_000)
238
- else:
239
- page.keyboard.press(key)
240
- elif do == "wait_for_selector":
241
- if not isinstance(selector, str) or not selector:
242
- raise ValueError(
243
- f"actions[{i}].selector required for wait_for_selector"
244
- )
245
- page.wait_for_selector(selector, timeout=10_000)
246
- elif do == "wait_for_state":
247
- state = action.get("state")
248
- if not isinstance(state, str) or not state:
249
- raise ValueError(f"actions[{i}].state required for wait_for_state")
250
- # Prefer explicit selector; else body[data-state].
251
- if isinstance(selector, str) and selector:
252
- page.wait_for_selector(selector, timeout=10_000)
253
- else:
254
- page.wait_for_selector(
255
- f'[data-state="{state}"]',
256
- timeout=10_000,
257
- )
258
- elif do in ("wait", "sleep"):
259
- ms = action.get("ms")
260
- if ms is None:
261
- ms = action.get("timeout_ms", 200)
262
- page.wait_for_timeout(int(ms))
263
- elif do == "select_option":
264
- # Native <select> — page.fill raises "Fill did not work on <select>";
265
- # select_option drives <option> by value (or visible label) and
266
- # fires change.
267
- if not isinstance(selector, str) or not selector:
268
- raise ValueError(
269
- f"actions[{i}].selector required for select_option")
270
- value = action.get("value")
271
- label = action.get("label")
272
- if value is None and label is None:
273
- raise ValueError(
274
- f"actions[{i}].value or label required for select_option")
275
- if value is not None:
276
- page.select_option(selector, value=value, timeout=10_000)
277
- else:
278
- page.select_option(selector, label=label, timeout=10_000)
279
- else:
312
+ handler = _ACTION_HANDLERS.get(do)
313
+ if handler is None:
280
314
  raise ValueError(f"actions[{i}]: unsupported do={do!r}")
315
+ handler(page, action, i, do)
281
316
 
282
317
 
283
318
  def _read_observed_state(page: Any) -> str:
@@ -399,6 +434,36 @@ class PlaywrightBrowserAdapter:
399
434
  browser.close()
400
435
 
401
436
 
437
+ def _validate_runtime_object(args: dict[str, Any]) -> tuple[str, str, str, list[dict[str, Any]]]:
438
+ """Validate Runtime Object fields and return (url, cap_type, state, actions).
439
+
440
+ Owns the field-level validation that execute_capture_plan previously
441
+ interleaved with capture logic. Keeps url/type/state/actions validation
442
+ in one locality so the handler reads cleanly.
443
+ """
444
+ url = args.get("url")
445
+ cap_type = args.get("type")
446
+ state = args.get("state")
447
+ actions = args.get("actions")
448
+
449
+ if not isinstance(url, str) or not url.strip():
450
+ raise ValueError("url is required")
451
+ if not isinstance(cap_type, str) or cap_type not in CAPTURE_TYPES:
452
+ raise ValueError(
453
+ f'type must be one of {sorted(CAPTURE_TYPES)}; got {cap_type!r}'
454
+ )
455
+ if not isinstance(state, str) or not state.strip():
456
+ raise ValueError("state is required")
457
+ if actions is None:
458
+ actions = []
459
+ if not isinstance(actions, list):
460
+ raise ValueError("actions must be an array")
461
+ for i, a in enumerate(actions):
462
+ if not isinstance(a, dict):
463
+ raise ValueError(f"actions[{i}] must be an object")
464
+ return url, cap_type, state, actions
465
+
466
+
402
467
  def execute_capture_plan(
403
468
  args: dict[str, Any],
404
469
  browser_adapter: BrowserAdapter | None = None,
@@ -419,32 +484,14 @@ def execute_capture_plan(
419
484
  label = artifact if isinstance(artifact, str) else ""
420
485
  return _failed(label, str(exc))
421
486
 
422
- url = args.get("url")
423
- cap_type = args.get("type")
424
- state = args.get("state")
425
- actions = args.get("actions")
487
+ url, cap_type, state, actions = _validate_runtime_object(args)
426
488
  artifact_path = args.get("artifact_path")
427
489
  overwrite = args.get("overwrite", False)
428
490
 
429
- if not isinstance(url, str) or not url.strip():
430
- raise ValueError("url is required")
431
- if not isinstance(cap_type, str) or cap_type not in CAPTURE_TYPES:
432
- raise ValueError(
433
- f'type must be one of {sorted(CAPTURE_TYPES)}; got {cap_type!r}'
434
- )
435
- if not isinstance(state, str) or not state.strip():
436
- raise ValueError("state is required")
437
491
  if not isinstance(artifact_path, str) or not artifact_path.strip():
438
492
  raise ValueError("artifact_path is required")
439
493
  if not isinstance(overwrite, bool):
440
494
  raise ValueError("overwrite must be a boolean")
441
- if actions is None:
442
- actions = []
443
- if not isinstance(actions, list):
444
- raise ValueError("actions must be an array")
445
- for i, a in enumerate(actions):
446
- if not isinstance(a, dict):
447
- raise ValueError(f"actions[{i}] must be an object")
448
495
 
449
496
  rel = artifact_path.strip()
450
497
  try:
@@ -110,6 +110,30 @@ def prototype_html_digest(raw: bytes) -> str:
110
110
  return hashlib.sha256(normalized).hexdigest()
111
111
 
112
112
 
113
+ def compute_binding_digest(
114
+ *, round_n: int, prototype_html_hash: str, report_ref: str,
115
+ summary: str, options: list[str],
116
+ ) -> dict[str, Any]:
117
+ """Build the binding record (canonical fields + SHA-256 digest).
118
+
119
+ Single source of truth for the binding shape — transaction.py uses it
120
+ to write, load_entry() uses it to validate on read, and
121
+ _valid_decision_entry() uses it for the G5 gate. Moving it here keeps
122
+ the write side and read side from drifting.
123
+ """
124
+ fields = {
125
+ "round": round_n,
126
+ "prototype_html_hash": prototype_html_hash,
127
+ "report_ref": report_ref,
128
+ "summary": summary,
129
+ "options": list(options),
130
+ }
131
+ canonical = json.dumps(
132
+ fields, ensure_ascii=False, sort_keys=True, separators=(",", ":")
133
+ ).encode("utf-8")
134
+ return {"digest": hashlib.sha256(canonical).hexdigest(), **fields}
135
+
136
+
113
137
  def _round_from_name(name: str) -> int | None:
114
138
  match = _ARTIFACT_ROUND.match(name)
115
139
  return int(match.group(1)) if match else None
@@ -166,17 +190,14 @@ def _valid_decision_entry(path: Path) -> bool:
166
190
  and isinstance(outcome, dict)
167
191
  ):
168
192
  return False
169
- fields = {
170
- "round": binding["round"],
171
- "prototype_html_hash": binding["prototype_html_hash"],
172
- "report_ref": binding["report_ref"],
173
- "summary": binding["summary"],
174
- "options": binding["options"],
175
- }
176
- canonical = json.dumps(
177
- fields, ensure_ascii=False, sort_keys=True, separators=(",", ":")
178
- ).encode("utf-8")
179
- return binding.get("digest") == hashlib.sha256(canonical).hexdigest()
193
+ expected = compute_binding_digest(
194
+ round_n=binding["round"],
195
+ prototype_html_hash=binding["prototype_html_hash"],
196
+ report_ref=binding["report_ref"],
197
+ summary=binding["summary"],
198
+ options=binding["options"],
199
+ )
200
+ return binding.get("digest") == expected["digest"]
180
201
 
181
202
 
182
203
  def inspect_preview(preview_dir: Path) -> PreviewSnapshot:
@@ -774,7 +774,8 @@ def collect_review(
774
774
  if validated:
775
775
  done.set()
776
776
 
777
- server = HTTPServer(("127.0.0.1", 0), Handler)
777
+ _preview_port = int(os.environ.get("DESIGN_PLAYBOOK_PREVIEW_PORT", "0"))
778
+ server = HTTPServer(("127.0.0.1", _preview_port), Handler)
778
779
  port = server.server_address[1]
779
780
  thread = threading.Thread(
780
781
  target=server.serve_forever, name="dpb-preview-http", daemon=True
@@ -86,7 +86,13 @@ def _tool_schema() -> dict[str, Any]:
86
86
 
87
87
 
88
88
 
89
- def handle_preview_prototype(args: dict[str, Any]) -> dict[str, Any]:
89
+ def _validate_preview_args(args: dict[str, Any]) -> tuple[str | None, str | None, str, int, str, list[str]]:
90
+ """Validate preview prototype arguments.
91
+
92
+ Owns the field-level validation so the handler reads cleanly and the
93
+ validation shape is testable in isolation. Returns
94
+ (path_arg, html, summary, round_n, report_ref, options).
95
+ """
90
96
  path_arg = args.get("path")
91
97
  html = args.get("html")
92
98
  summary = args.get("summary")
@@ -106,6 +112,11 @@ def handle_preview_prototype(args: dict[str, Any]) -> dict[str, Any]:
106
112
  raise ValueError("html must be a string")
107
113
  if not isinstance(options, list) or not all(isinstance(o, str) for o in options):
108
114
  raise ValueError("options must be string[]")
115
+ return path_arg, html, summary, round_n, report_ref, options
116
+
117
+
118
+ def handle_preview_prototype(args: dict[str, Any]) -> dict[str, Any]:
119
+ path_arg, html, summary, round_n, report_ref, options = _validate_preview_args(args)
109
120
 
110
121
  try:
111
122
  return run_preview_transaction(
@@ -81,9 +81,9 @@ class PreviewMcpStdioTests(unittest.TestCase):
81
81
  def test_active_lock_returns_structured_error_over_stdio(self) -> None:
82
82
  html = "<html><body>locked</body></html>"
83
83
  options = ["确认通过", "需要修改"]
84
- binding = transaction._binding(
84
+ binding = transaction.compute_binding_digest(
85
85
  round_n=1,
86
- prototype_hash=prototype_html_digest(html.encode("utf-8")),
86
+ prototype_html_hash=prototype_html_digest(html.encode("utf-8")),
87
87
  report_ref="report.md", summary="review", options=options,
88
88
  )
89
89
  with tempfile.TemporaryDirectory() as tmp:
@@ -561,9 +561,9 @@ class PreviewDecisionTransactionTests(unittest.TestCase):
561
561
  with self.subTest(binding_matches=binding_matches), tempfile.TemporaryDirectory() as tmp:
562
562
  prototype = Path(tmp) / "round-1.html"
563
563
  prototype.write_text("reviewed", encoding="utf-8")
564
- digest = transaction._binding(
564
+ digest = transaction.compute_binding_digest(
565
565
  round_n=1,
566
- prototype_hash=prototype_html_digest(
566
+ prototype_html_hash=prototype_html_digest(
567
567
  prototype.read_bytes()
568
568
  ),
569
569
  report_ref="report.md", summary="summary",
@@ -38,8 +38,8 @@ def _seed_round(
38
38
  *, confirmed: bool = True, feedback: str = "ok",
39
39
  ) -> dict:
40
40
  digest = prototype_html_digest(html.encode("utf-8"))
41
- binding = transaction._binding(
42
- round_n=round_n, prototype_hash=digest, report_ref="r.md",
41
+ binding = transaction.compute_binding_digest(
42
+ round_n=round_n, prototype_html_hash=digest, report_ref="r.md",
43
43
  summary="s", options=["确认通过", "需要修改"])
44
44
  entry = {
45
45
  "schema_version": 1,
@@ -501,8 +501,8 @@ class ForkTests(unittest.TestCase):
501
501
  src.mkdir()
502
502
  # path-mode style: decision entry but no round-N.html snapshot
503
503
  digest = prototype_html_digest(b"<html>x</html>")
504
- binding = transaction._binding(
505
- round_n=1, prototype_hash=digest, report_ref="r.md",
504
+ binding = transaction.compute_binding_digest(
505
+ round_n=1, prototype_html_hash=digest, report_ref="r.md",
506
506
  summary="s", options=["确认通过", "需要修改"])
507
507
  entry = {
508
508
  "schema_version": 1,
@@ -7,7 +7,6 @@ and result construction for one Preview decision.
7
7
  from __future__ import annotations
8
8
 
9
9
  import errno
10
- import hashlib
11
10
  import json
12
11
  import os
13
12
  import tempfile
@@ -25,7 +24,11 @@ else:
25
24
 
26
25
  from design_playbook.mcp.preview.control import _format_feedback
27
26
  from design_playbook.mcp.preview.i18n import CONFIRM_LABELS
28
- from design_playbook.mcp.preview.integrity import evaluate_feedback_floor, prototype_html_digest
27
+ from design_playbook.mcp.preview.integrity import (
28
+ compute_binding_digest,
29
+ evaluate_feedback_floor,
30
+ prototype_html_digest,
31
+ )
29
32
  from design_playbook.mcp.preview.util import _now_iso
30
33
 
31
34
  BrowserCollector = Callable[[Path, str, list[str], int], dict[str, Any]]
@@ -40,19 +43,38 @@ def _preview_dir_for(path: Path | None) -> Path:
40
43
  return scratch
41
44
 
42
45
 
43
- def _ensure_prototype(path_arg: str | None, html: str | None, round_n: int,
44
- preview_dir: Path) -> Path:
46
+ def _resolve_prototype(
47
+ path_arg: str | None, html: str | None, round_n: int,
48
+ preview_dir: Path,
49
+ ) -> tuple[Path, str]:
50
+ """Resolve prototype source to a path and compute its digest.
51
+
52
+ Single source for prototype resolution: path mode reads the file, html
53
+ mode computes from inline bytes. Returns (prototype_path, digest) so
54
+ callers can build the binding without re-reading. Does NOT write the
55
+ html file — that is _ensure_prototype's job.
56
+ """
45
57
  if path_arg:
46
- p = Path(path_arg)
47
- if not p.is_file():
58
+ prototype = Path(path_arg)
59
+ if not prototype.is_file():
48
60
  raise ValueError(f"prototype path does not exist: {path_arg}")
49
- return p
61
+ return prototype, prototype_html_digest(prototype.read_bytes())
50
62
  if not html:
51
63
  raise ValueError("path or html is required")
52
- preview_dir.mkdir(parents=True, exist_ok=True)
53
- target = preview_dir / f"round-{round_n}.html"
54
- target.write_text(html, encoding="utf-8")
55
- return target
64
+ return (
65
+ preview_dir / f"round-{round_n}.html",
66
+ prototype_html_digest(html.encode("utf-8")),
67
+ )
68
+
69
+
70
+ def _ensure_prototype(path_arg: str | None, html: str | None, round_n: int,
71
+ preview_dir: Path) -> Path:
72
+ """Resolve and materialize the prototype file on disk."""
73
+ prototype, _ = _resolve_prototype(path_arg, html, round_n, preview_dir)
74
+ if not path_arg:
75
+ preview_dir.mkdir(parents=True, exist_ok=True)
76
+ prototype.write_text(html, encoding="utf-8")
77
+ return prototype
56
78
 
57
79
 
58
80
  def self_check_floor() -> None:
@@ -444,23 +466,6 @@ def json_text(value: dict[str, Any]) -> str:
444
466
  return json.dumps(value, ensure_ascii=False, indent=2) + "\n"
445
467
 
446
468
 
447
- def _binding(
448
- *, round_n: int, prototype_hash: str, report_ref: str,
449
- summary: str, options: list[str],
450
- ) -> dict[str, Any]:
451
- fields = {
452
- "round": round_n,
453
- "prototype_html_hash": prototype_hash,
454
- "report_ref": report_ref,
455
- "summary": summary,
456
- "options": list(options),
457
- }
458
- canonical = json.dumps(
459
- fields, ensure_ascii=False, sort_keys=True, separators=(",", ":")
460
- ).encode("utf-8")
461
- return {"digest": hashlib.sha256(canonical).hexdigest(), **fields}
462
-
463
-
464
469
  def load_entry(path: Path) -> dict[str, Any] | None:
465
470
  if not path.is_file():
466
471
  return None
@@ -483,9 +488,9 @@ def load_entry(path: Path) -> dict[str, Any] | None:
483
488
  binding_valid = False
484
489
  if isinstance(binding, dict):
485
490
  try:
486
- expected = _binding(
491
+ expected = compute_binding_digest(
487
492
  round_n=round_n,
488
- prototype_hash=binding["prototype_html_hash"],
493
+ prototype_html_hash=binding["prototype_html_hash"],
489
494
  report_ref=binding["report_ref"], summary=binding["summary"],
490
495
  options=binding["options"],
491
496
  )
@@ -702,17 +707,11 @@ def run_preview_transaction(
702
707
  summary = summary.strip()
703
708
  report_ref = report_ref.strip()
704
709
  preview_dir = _preview_dir_for(Path(path_arg) if path_arg else None)
705
- if path_arg:
706
- prototype = Path(path_arg)
707
- if not prototype.is_file():
708
- raise ValueError(f"prototype path does not exist: {path_arg}")
709
- prototype_hash = prototype_html_digest(prototype.read_bytes())
710
- else:
711
- if not html:
712
- raise ValueError("path or html is required")
713
- prototype_hash = prototype_html_digest(html.encode("utf-8"))
714
- binding = _binding(
715
- round_n=round_n, prototype_hash=prototype_hash,
710
+ _prototype, prototype_hash = _resolve_prototype(
711
+ path_arg, html, round_n, preview_dir,
712
+ )
713
+ binding = compute_binding_digest(
714
+ round_n=round_n, prototype_html_hash=prototype_hash,
716
715
  report_ref=report_ref, summary=summary, options=options,
717
716
  )
718
717
  entry_path = preview_dir / f"decision-round-{round_n}.json"
@@ -756,16 +755,18 @@ def _run_locked(
756
755
  decision_id: str,
757
756
  ) -> dict[str, Any]:
758
757
  entry_path = preview_dir / f"decision-round-{round_n}.json"
759
- if path_arg:
760
- prototype = Path(path_arg)
761
- if not prototype.is_file():
762
- raise ValueError(f"prototype path does not exist: {path_arg}")
763
- prototype_hash = prototype_html_digest(prototype.read_bytes())
764
- else:
765
- if not html:
766
- raise ValueError("path or html is required")
767
- prototype_hash = prototype_html_digest(html.encode("utf-8"))
768
- prototype = preview_dir / f"round-{round_n}.html"
758
+ # Re-resolve inside the lock to detect TOCTOU: if the path-mode file
759
+ # changed between run_preview_transaction and lock acquisition, the
760
+ # recomputed hash will not match binding["digest"].
761
+ _prototype, locked_hash = _resolve_prototype(
762
+ path_arg, html, round_n, preview_dir,
763
+ )
764
+ if locked_hash != prototype_hash:
765
+ raise TransactionConflict(
766
+ "prototype changed between binding and lock acquisition",
767
+ retryable=False, round_n=round_n, decision_id=decision_id,
768
+ artifact=str(_prototype),
769
+ )
769
770
 
770
771
  existing = load_entry(entry_path)
771
772
  if existing is not None:
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "design-playbook",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
7
+ "dsh-plugin",
8
+ "dsh-bundle",
7
9
  "ui",
8
10
  "ux",
9
11
  "design",
@@ -17,6 +19,7 @@
17
19
  ],
18
20
  "license": "MIT",
19
21
  "author": "Bandersnatch0x (https://github.com/Bandersnatch0x)",
22
+ "main": "lib/index.js",
20
23
  "homepage": "https://github.com/Bandersnatch0x/design-playbook",
21
24
  "repository": {
22
25
  "type": "git",
@@ -28,11 +31,13 @@
28
31
  "commands",
29
32
  "mcp",
30
33
  "scripts",
34
+ "lib",
31
35
  "design_playbook.py",
32
36
  "examples",
33
37
  "codex",
34
38
  "NOTICE",
35
- "!**/__pycache__"
39
+ "!**/__pycache__",
40
+ "!lib/test_commands.js"
36
41
  ],
37
42
  "pi": {
38
43
  "skills": [
@@ -42,5 +47,10 @@
42
47
  "./commands"
43
48
  ],
44
49
  "image": "https://raw.githubusercontent.com/Bandersnatch0x/design-playbook/main/packages/design-playbook/showcase/screenshots/hero.png"
50
+ },
51
+ "dsh": {
52
+ "bundle": {
53
+ "patch": "./cordis.patch.yml"
54
+ }
45
55
  }
46
56
  }