design-playbook 0.12.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.
Files changed (45) hide show
  1. package/codex/AGENTS.md +16 -2
  2. package/design_playbook.py +20 -0
  3. package/lib/index.js +172 -0
  4. package/mcp/evidence/capture_contract.py +268 -0
  5. package/mcp/evidence/capture_runtime.py +552 -0
  6. package/mcp/evidence/containment.py +164 -0
  7. package/mcp/evidence/ledger_syntax.py +131 -0
  8. package/mcp/evidence/server.py +16 -570
  9. package/mcp/evidence/test_capture_contract.py +355 -0
  10. package/mcp/evidence/test_containment.py +581 -0
  11. package/mcp/evidence/test_ledger_syntax.py +252 -0
  12. package/mcp/evidence/test_server_stdio.py +254 -426
  13. package/mcp/preview/compatibility.py +158 -0
  14. package/mcp/preview/control.js +7 -9
  15. package/mcp/preview/control.py +3 -7
  16. package/mcp/preview/integrity.py +383 -0
  17. package/mcp/preview/{browser.py → review_session.py} +63 -16
  18. package/mcp/preview/server.py +26 -10
  19. package/mcp/preview/test_browser_control.py +194 -177
  20. package/mcp/preview/test_integrity.py +208 -0
  21. package/mcp/preview/test_server_stdio.py +11 -450
  22. package/mcp/preview/test_transaction.py +24 -19
  23. package/mcp/preview/test_versions.py +81 -28
  24. package/mcp/preview/test_versions_freeze.py +176 -0
  25. package/mcp/preview/transaction.py +81 -104
  26. package/mcp/preview/util.py +1 -21
  27. package/mcp/preview/versions.py +78 -132
  28. package/mcp/test_transport.py +6 -2
  29. package/package.json +13 -2
  30. package/scripts/__init__.py +0 -0
  31. package/scripts/g1_spec.py +81 -0
  32. package/scripts/g2_g4_pointback.py +310 -0
  33. package/scripts/g5_preview.py +265 -0
  34. package/scripts/g6_evidence.py +252 -0
  35. package/scripts/g6_records.py +56 -0
  36. package/scripts/g6_warnings.py +104 -0
  37. package/scripts/g7_contract_drift.py +2 -2
  38. package/scripts/run_facts.py +212 -0
  39. package/scripts/run_status.py +127 -147
  40. package/scripts/stages.py +90 -0
  41. package/scripts/test_verdict_syntax.py +239 -0
  42. package/scripts/validate_run.py +67 -978
  43. package/scripts/verdict_syntax.py +96 -0
  44. package/mcp/preview/test_anchor_v2.py +0 -69
  45. package/scripts/_preview_integrity.py +0 -288
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`
@@ -0,0 +1,20 @@
1
+ """Namespace alias: absolute ``design_playbook.*`` imports inside this plugin.
2
+
3
+ The plugin directory is ``design-playbook`` (hyphen), so Python's import
4
+ system cannot load it under the identifier ``design_playbook`` — FileFinder
5
+ matches directory names literally. This module aliases the package root onto
6
+ ``design_playbook.__path__`` so that, once the one bootstrap (ADR-0022) puts
7
+ the package root on ``sys.path``, absolute imports such as
8
+ ``design_playbook.mcp.preview.integrity`` and
9
+ ``design_playbook.scripts.stages`` resolve to the real ``mcp/`` and
10
+ ``scripts/`` trees below.
11
+
12
+ The alias also keeps the import seam namespaced: a bare ``mcp.*`` import
13
+ would collide with the PyPI ``mcp`` SDK when the host has it installed.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ __path__ = [str(Path(__file__).resolve().parent)]
20
+ __all__: list[str] = []
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
+ }
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env python3
2
+ """Capture contract v1 rules, owned by the bundled Evidence runtime.
3
+
4
+ ADR-0018 enforcement site 1: this module is the single owner of the v1
5
+ contract surface — the write-side parse/normalize authority, the read-side
6
+ full-shape snapshot validator, and the contract-fields JSON Schema fragment
7
+ the provider tool schema composes. The provider (server.py) keeps only
8
+ Runtime Object fields, path/overwrite boundaries, and Playwright I/O; G6
9
+ (scripts/validate_run.py) validates bound manifest request snapshots through
10
+ ``validate_capture_snapshot`` instead of hand-written partial checks.
11
+
12
+ Named ``capture_contract.py`` to avoid collision with
13
+ ``scripts/contract_v1.py`` (the persistent contract, ADR-0017).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ CAPTURE_SCHEMA_VERSION = 1
22
+ COLOR_SCHEMES = frozenset({"light", "dark", "no-preference"})
23
+ MIN_VIEWPORT_DPR = 0.1
24
+ RECAPTURE_HINT = "recapture with capture contract schemaVersion=1"
25
+ FREEZE_DEFAULTS = {
26
+ "enabled": True,
27
+ "waitFonts": True,
28
+ "networkIdle": False,
29
+ }
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class CaptureFact:
34
+ """One host-neutral capture-contract violation (empty facts = valid)."""
35
+
36
+ code: str
37
+ detail: str
38
+ expected: str = ""
39
+ actual: str = ""
40
+
41
+
42
+ def _is_schema_version(value: object) -> bool:
43
+ return type(value) is int and value == CAPTURE_SCHEMA_VERSION
44
+
45
+
46
+ def _bad_viewport(viewport: dict[str, Any]) -> str | None:
47
+ """First malformed viewport field, or None when the shape is valid.
48
+
49
+ Mirrors the parser's field rules exactly so the read side and write side
50
+ cannot disagree on what a valid viewport is.
51
+ """
52
+ width = viewport.get("width")
53
+ height = viewport.get("height")
54
+ dpr = viewport.get("devicePixelRatio")
55
+ scheme = viewport.get("colorScheme")
56
+ if type(width) is not int or width < 1:
57
+ return "viewport.width must be a positive integer"
58
+ if type(height) is not int or height < 1:
59
+ return "viewport.height must be a positive integer"
60
+ if (
61
+ type(dpr) not in (int, float)
62
+ or not math.isfinite(dpr)
63
+ or dpr < MIN_VIEWPORT_DPR
64
+ ):
65
+ return (
66
+ "viewport.devicePixelRatio must be a number greater than or equal "
67
+ f"to {MIN_VIEWPORT_DPR}"
68
+ )
69
+ if not isinstance(scheme, str) or scheme not in COLOR_SCHEMES:
70
+ return (
71
+ f"viewport.colorScheme must be one of {sorted(COLOR_SCHEMES)}; "
72
+ f"got {scheme!r}"
73
+ )
74
+ return None
75
+
76
+
77
+ def _bad_freeze(freeze: dict[str, Any]) -> str | None:
78
+ """First malformed freeze field, or None when the shape is valid."""
79
+ for key in FREEZE_DEFAULTS:
80
+ if not isinstance(freeze.get(key), bool):
81
+ return f"freeze.{key} must be a boolean"
82
+ return None
83
+
84
+
85
+ def parse_capture_contract(args: dict[str, Any]) -> dict[str, Any]:
86
+ """Validate capture contract v1 fields and return a normalized request.
87
+
88
+ Write authority (ADR-0018): raises ValueError with a recapture instruction
89
+ for missing/unknown versions or an incomplete viewport. Pure — no browser
90
+ side effects. The normalized output is what the provider echoes into the
91
+ manifest request snapshot, so real snapshots always carry freeze defaults.
92
+ """
93
+ if "schemaVersion" not in args:
94
+ raise ValueError(
95
+ f"capture contract schemaVersion is required; {RECAPTURE_HINT}"
96
+ )
97
+ version = args.get("schemaVersion")
98
+ if not _is_schema_version(version):
99
+ raise ValueError(
100
+ f"unsupported capture schemaVersion {version!r}; {RECAPTURE_HINT}"
101
+ )
102
+ viewport = args.get("viewport")
103
+ if not isinstance(viewport, dict):
104
+ raise ValueError(
105
+ f"viewport object is required for schemaVersion=1; {RECAPTURE_HINT}"
106
+ )
107
+ bad_viewport = _bad_viewport(viewport)
108
+ if bad_viewport is not None:
109
+ raise ValueError(bad_viewport)
110
+ width = viewport["width"]
111
+ height = viewport["height"]
112
+ dpr = viewport["devicePixelRatio"]
113
+ scheme = viewport["colorScheme"]
114
+
115
+ freeze_raw = args.get("freeze")
116
+ if freeze_raw is None:
117
+ freeze_raw = {}
118
+ if not isinstance(freeze_raw, dict):
119
+ raise ValueError("freeze must be an object when provided")
120
+ freeze = {
121
+ key: freeze_raw.get(key, FREEZE_DEFAULTS[key])
122
+ for key in FREEZE_DEFAULTS
123
+ }
124
+ bad_freeze = _bad_freeze(freeze)
125
+ if bad_freeze is not None:
126
+ raise ValueError(bad_freeze)
127
+
128
+ return {
129
+ "schemaVersion": CAPTURE_SCHEMA_VERSION,
130
+ "viewport": {
131
+ "width": width,
132
+ "height": height,
133
+ "devicePixelRatio": float(dpr),
134
+ "colorScheme": scheme,
135
+ },
136
+ "freeze": freeze,
137
+ }
138
+
139
+
140
+ def validate_capture_snapshot(snapshot: object) -> list[CaptureFact]:
141
+ """Read authority: full-shape validation of a bound manifest snapshot.
142
+
143
+ Host-neutral — never raises, returns facts (empty list = valid). Strict on
144
+ the v1 full shape: schemaVersion=1, a complete typed viewport, and a
145
+ complete boolean freeze. The parser normalizes defaults at capture time;
146
+ the read side requires the recorded snapshot to be self-contained so the
147
+ manifest alone can reproduce the capture (ADR-0018). Malformed viewport
148
+ shape or missing freeze therefore fail closed (sanctioned correction; was
149
+ lax in the old hand-written G6 checks). Unknown extra keys are tolerated
150
+ (host-neutral forward compatibility).
151
+ """
152
+ if not isinstance(snapshot, dict):
153
+ return [CaptureFact(
154
+ "missing_schema_version",
155
+ "no request snapshot on the bound entry",
156
+ expected="schemaVersion=1 with viewport and freeze",
157
+ actual=("None" if snapshot is None else type(snapshot).__name__),
158
+ )]
159
+ version = snapshot.get("schemaVersion")
160
+ if not _is_schema_version(version):
161
+ return [CaptureFact(
162
+ ("missing_schema_version" if version is None
163
+ else "unsupported_schema_version"),
164
+ ("missing schemaVersion" if version is None
165
+ else f"unsupported schemaVersion {version!r}"),
166
+ expected="schemaVersion=1",
167
+ actual=repr(version),
168
+ )]
169
+ viewport = snapshot.get("viewport")
170
+ if not isinstance(viewport, dict):
171
+ return [CaptureFact(
172
+ "missing_viewport",
173
+ "viewport object is required for schemaVersion=1",
174
+ expected="viewport width/height/devicePixelRatio/colorScheme",
175
+ actual=type(viewport).__name__ if viewport is not None else "missing",
176
+ )]
177
+ bad_viewport = _bad_viewport(viewport)
178
+ if bad_viewport is not None:
179
+ return [CaptureFact(
180
+ "bad_viewport_shape",
181
+ bad_viewport,
182
+ expected="viewport width/height/devicePixelRatio/colorScheme",
183
+ actual=bad_viewport,
184
+ )]
185
+ freeze = snapshot.get("freeze")
186
+ if not isinstance(freeze, dict):
187
+ return [CaptureFact(
188
+ "missing_freeze",
189
+ "freeze snapshot is required on the bound entry",
190
+ expected="freeze enabled/waitFonts/networkIdle booleans",
191
+ actual=type(freeze).__name__ if freeze is not None else "missing",
192
+ )]
193
+ bad_freeze = _bad_freeze(freeze)
194
+ if bad_freeze is not None:
195
+ return [CaptureFact(
196
+ "bad_freeze_shape",
197
+ bad_freeze,
198
+ expected="freeze enabled/waitFonts/networkIdle booleans",
199
+ actual=bad_freeze,
200
+ )]
201
+ return []
202
+
203
+
204
+ def capture_contract_schema_fragment() -> dict[str, Any]:
205
+ """JSON Schema fragment for the contract fields (schemaVersion/viewport/freeze).
206
+
207
+ The provider composes this into its tool schema alongside its Runtime
208
+ Object fields. const/enum/required/default all come from the same module
209
+ constants the parser uses, so the schema and the parser cannot drift.
210
+ """
211
+ return {
212
+ "properties": {
213
+ "schemaVersion": {
214
+ "type": "integer",
215
+ "description": "Capture contract version. Only 1 is supported.",
216
+ "const": CAPTURE_SCHEMA_VERSION,
217
+ },
218
+ "viewport": {
219
+ "type": "object",
220
+ "description": (
221
+ "Required capture viewport. Provider does not invent "
222
+ "desktop defaults."
223
+ ),
224
+ "properties": {
225
+ "width": {"type": "integer", "minimum": 1},
226
+ "height": {"type": "integer", "minimum": 1},
227
+ "devicePixelRatio": {
228
+ "type": "number",
229
+ "minimum": MIN_VIEWPORT_DPR,
230
+ },
231
+ "colorScheme": {
232
+ "type": "string",
233
+ "enum": sorted(COLOR_SCHEMES),
234
+ },
235
+ },
236
+ "required": [
237
+ "width",
238
+ "height",
239
+ "devicePixelRatio",
240
+ "colorScheme",
241
+ ],
242
+ "additionalProperties": False,
243
+ },
244
+ "freeze": {
245
+ "type": "object",
246
+ "description": (
247
+ "Deterministic freeze controls. Defaults: "
248
+ "enabled=true, waitFonts=true, networkIdle=false."
249
+ ),
250
+ "properties": {
251
+ "enabled": {
252
+ "type": "boolean",
253
+ "default": FREEZE_DEFAULTS["enabled"],
254
+ },
255
+ "waitFonts": {
256
+ "type": "boolean",
257
+ "default": FREEZE_DEFAULTS["waitFonts"],
258
+ },
259
+ "networkIdle": {
260
+ "type": "boolean",
261
+ "default": FREEZE_DEFAULTS["networkIdle"],
262
+ },
263
+ },
264
+ "additionalProperties": False,
265
+ },
266
+ },
267
+ "required": ["schemaVersion", "viewport"],
268
+ }