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 +16 -2
- package/lib/index.js +172 -0
- package/mcp/evidence/capture_runtime.py +137 -90
- package/mcp/preview/integrity.py +32 -11
- package/mcp/preview/review_session.py +2 -1
- package/mcp/preview/server.py +12 -1
- package/mcp/preview/test_server_stdio.py +2 -2
- package/mcp/preview/test_transaction.py +2 -2
- package/mcp/preview/test_versions.py +4 -4
- package/mcp/preview/transaction.py +52 -51
- package/package.json +12 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
if
|
|
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
|
|
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:
|
package/mcp/preview/integrity.py
CHANGED
|
@@ -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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
package/mcp/preview/server.py
CHANGED
|
@@ -86,7 +86,13 @@ def _tool_schema() -> dict[str, Any]:
|
|
|
86
86
|
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
def
|
|
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.
|
|
84
|
+
binding = transaction.compute_binding_digest(
|
|
85
85
|
round_n=1,
|
|
86
|
-
|
|
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.
|
|
564
|
+
digest = transaction.compute_binding_digest(
|
|
565
565
|
round_n=1,
|
|
566
|
-
|
|
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.
|
|
42
|
-
round_n=round_n,
|
|
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.
|
|
505
|
-
round_n=1,
|
|
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
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
if not
|
|
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
|
|
61
|
+
return prototype, prototype_html_digest(prototype.read_bytes())
|
|
50
62
|
if not html:
|
|
51
63
|
raise ValueError("path or html is required")
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 =
|
|
491
|
+
expected = compute_binding_digest(
|
|
487
492
|
round_n=round_n,
|
|
488
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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.
|
|
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
|
}
|