design-playbook 0.24.3 → 0.25.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 (93) hide show
  1. package/codex/AGENTS.md +1 -1
  2. package/commands/component-distill.md +8 -0
  3. package/cordis.patch.yml +19 -0
  4. package/lib/index.js +4 -2
  5. package/mcp/evidence/capture_runtime.py +35 -8
  6. package/mcp/evidence/containment.py +91 -0
  7. package/mcp/evidence/server.py +2 -1
  8. package/mcp/preview/control.css +172 -144
  9. package/mcp/preview/control.html +80 -86
  10. package/mcp/preview/control.js +286 -63
  11. package/mcp/preview/control.py +10 -4
  12. package/mcp/preview/control.review.js +64 -77
  13. package/mcp/preview/i18n.py +49 -43
  14. package/mcp/preview/integrity.py +2 -0
  15. package/mcp/preview/ledger.py +65 -0
  16. package/mcp/preview/pin_bridge.py +11 -1
  17. package/mcp/preview/review_session.py +29 -3
  18. package/mcp/preview/server.py +25 -3
  19. package/mcp/preview/transaction.py +50 -1
  20. package/mcp/run_console/actions.py +133 -7
  21. package/mcp/run_console/app.css +87 -0
  22. package/mcp/run_console/app.js +275 -15
  23. package/mcp/run_console/diagnostic_export.py +403 -0
  24. package/mcp/run_console/export_transaction.py +208 -0
  25. package/mcp/run_console/http_server.py +106 -6
  26. package/mcp/run_console/request_security.py +19 -2
  27. package/mcp/run_console/session.py +22 -2
  28. package/mcp/run_console/source_registry.py +23 -1
  29. package/package.json +10 -2
  30. package/scripts/component_candidates.py +380 -0
  31. package/scripts/contract_v1.py +5 -0
  32. package/scripts/eval_decisions.py +303 -0
  33. package/scripts/evidence_manifest.py +271 -0
  34. package/scripts/g10_design_decisions.py +28 -14
  35. package/scripts/g1_spec.py +43 -2
  36. package/scripts/g2_g4_pointback.py +68 -4
  37. package/scripts/g5_preview.py +93 -3
  38. package/scripts/g6_evidence.py +82 -6
  39. package/scripts/g6_warnings.py +24 -14
  40. package/scripts/g8_run_registry.py +20 -7
  41. package/scripts/g9_shaping.py +42 -11
  42. package/scripts/governance_jsonl.py +42 -0
  43. package/scripts/promotion_governance.py +185 -0
  44. package/scripts/run_continuation.py +21 -4
  45. package/scripts/run_metadata.py +0 -7
  46. package/scripts/shaping_log.py +38 -2
  47. package/scripts/stages.py +5 -0
  48. package/scripts/validate_run.py +16 -0
  49. package/skills/component-distill/SKILL.md +49 -0
  50. package/skills/craft-guard/SKILL.md +1 -1
  51. package/skills/craft-guard/references/detectors.md +1 -0
  52. package/skills/design-baseline/SKILL.md +2 -0
  53. package/skills/design-baseline/scripts/design_baseline.py +224 -0
  54. package/skills/design-playbook/SKILL.md +12 -2
  55. package/skills/design-playbook/references/observe-ops.md +2 -1
  56. package/skills/design-playbook/references/preview-ops.md +2 -1
  57. package/skills/ui-evaluator/SKILL.md +1 -1
  58. package/skills/ui-picker/SKILL.md +1 -1
  59. package/skills/ux-spec/SKILL.md +5 -3
  60. package/skills/ux-spec/references/spec-template.md +2 -1
  61. package/mcp/evidence/test_capture_contract.py +0 -355
  62. package/mcp/evidence/test_containment.py +0 -733
  63. package/mcp/evidence/test_delivery_matrix.py +0 -159
  64. package/mcp/evidence/test_disclosure.py +0 -309
  65. package/mcp/evidence/test_evidence_preflight.py +0 -288
  66. package/mcp/evidence/test_handoff.py +0 -672
  67. package/mcp/evidence/test_handoff_i18n.py +0 -125
  68. package/mcp/evidence/test_ledger_syntax.py +0 -251
  69. package/mcp/evidence/test_page_defects.py +0 -90
  70. package/mcp/evidence/test_server_stdio.py +0 -821
  71. package/mcp/preview/test_browser_control.py +0 -1284
  72. package/mcp/preview/test_i18n_labels.py +0 -92
  73. package/mcp/preview/test_integrity.py +0 -237
  74. package/mcp/preview/test_preview_usability.py +0 -247
  75. package/mcp/preview/test_server_stdio.py +0 -200
  76. package/mcp/preview/test_transaction.py +0 -810
  77. package/mcp/preview/test_versions.py +0 -625
  78. package/mcp/preview/test_versions_freeze.py +0 -176
  79. package/mcp/run_console/test_actions.py +0 -723
  80. package/mcp/run_console/test_contract.py +0 -668
  81. package/mcp/run_console/test_diagnostic_export_gate.py +0 -1540
  82. package/mcp/run_console/test_http_server.py +0 -1041
  83. package/mcp/run_console/test_parity.py +0 -1129
  84. package/mcp/run_console/test_read_only_trial.py +0 -583
  85. package/mcp/run_console/test_repair_packet.py +0 -840
  86. package/mcp/run_console/test_request_security.py +0 -325
  87. package/mcp/run_console/test_role_attestation_gate.py +0 -945
  88. package/mcp/run_console/test_session.py +0 -336
  89. package/mcp/run_console/test_snapshot_builder.py +0 -912
  90. package/mcp/run_console/test_source_registry.py +0 -950
  91. package/mcp/run_console/test_ui.py +0 -444
  92. package/mcp/run_console/test_ui_actions.py +0 -295
  93. package/mcp/run_console/test_ui_browser.py +0 -951
package/codex/AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- generated-by design-playbook v0.24.3 -->
1
+ <!-- generated-by design-playbook v0.25.0 -->
2
2
  # design-playbook for Codex
3
3
 
4
4
  ## Install (path of record)
@@ -0,0 +1,8 @@
1
+ ---
2
+ description: Cross-run component backflow — derive recurring components from iterated pages and render a propose-only DESIGN.md promotion proposal (report-only, user-gated)
3
+ ---
4
+
5
+ Cross-run **component backflow** over `.scratch/<run>/` decision reports in the user project. Not a step of a single Design I/O run. Run skill **component-distill**. Proposal header **`component-distill/v1`**; report-only — never writes `DESIGN.md` or any authority; promotion is a user decision recorded in the governance log and executed only by `design_baseline.py promote`.
6
+
7
+ Output path:
8
+ $ARGUMENTS
@@ -0,0 +1,19 @@
1
+ # design-playbook dsh bundle patch
2
+ #
3
+ # Mounts the design-playbook plugin (this package's lib/index.js), which
4
+ # registers the bundled skills as a provider on ctx.skills. The plugin
5
+ # locates its own skills/ directory via __dirname, so no path expression is
6
+ # needed and no cwd-dependent resolution is involved.
7
+ #
8
+ # Note: the Cordis `!!js` evaluation scope provides no `require` (only Node
9
+ # globals plus ctx-provided values like dshHomePath), so pointing a
10
+ # skill-filesystem customSkillDirs row at package resources via
11
+ # `require.resolve` does not work. The plugin route is the supported way for
12
+ # a package to contribute its own skills.
13
+ #
14
+ # P2 (MCP + commands) will append rows here.
15
+
16
+ - insert:
17
+ - id: design-playbook
18
+ name: 'design-playbook'
19
+ disabled: false
package/lib/index.js CHANGED
@@ -8,8 +8,9 @@
8
8
  * `skills/` directory. The plugin locates the directory via `__dirname`,
9
9
  * so no `!!js` expression and no cwd-dependent resolution is involved.
10
10
  *
11
- * 2. Slash commands (ctx.commands) — `design-io`, `doctor`,
12
- * `run-handoff`, `run-review`, `run-status`, `ui-review`, `ux-spec` —
11
+ * 2. Slash commands (ctx.commands) — `component-distill`, `design-io`,
12
+ * `doctor`, `run-handoff`, `run-review`, `run-status`, `ui-review`,
13
+ * `ux-spec` —
13
14
  * that load the matching `commands/<name>.md` prompt, substitute
14
15
  * `$ARGUMENTS` with the raw trailing input, and inject it as a
15
16
  * user-role follow-up turn via `agent.followup()`.
@@ -87,6 +88,7 @@ exports.createUserMessageFromPrompt = createUserMessageFromPrompt
87
88
  * substituted) is injected as a user follow-up turn.
88
89
  */
89
90
  const COMMAND_NAMES = [
91
+ 'component-distill',
90
92
  'design-io',
91
93
  'doctor',
92
94
  'run-handoff',
@@ -110,6 +110,10 @@ def _failed(
110
110
  }
111
111
  if request is not None:
112
112
  payload["request"] = request
113
+ if written_path and _run_root_misrooted():
114
+ # Same misroot warning as the success payload: a failed write outside
115
+ # the run tree is exactly where the orchestrator needs the hint.
116
+ payload["warnings"] = [_MISROOTED_WARNING]
113
117
  return payload
114
118
 
115
119
 
@@ -138,6 +142,10 @@ def _captured(
138
142
  "written_path": written_path,
139
143
  "request": request,
140
144
  }
145
+ if _run_root_misrooted():
146
+ # The stderr warning fires once per process and may never reach the
147
+ # model; the payload is what the orchestrator actually reads.
148
+ payload["warnings"] = [_MISROOTED_WARNING]
141
149
  if probe_artifact:
142
150
  payload["probe_artifact"] = probe_artifact
143
151
  return payload
@@ -271,19 +279,38 @@ _RUN_MARKERS = ("plan.md", "point-back.md")
271
279
  _warned_run_root = False
272
280
 
273
281
 
282
+ _MISROOTED_WARNING = (
283
+ "run root resolved to a markerless cwd (DESIGN_PLAYBOOK_RUN_ROOT "
284
+ "unset or '.'); written_path is outside the run tree — set "
285
+ "DESIGN_PLAYBOOK_RUN_ROOT to the run root (.scratch/<run>/) "
286
+ "before binding this artifact"
287
+ )
288
+
289
+
290
+ def _run_root_misrooted() -> bool:
291
+ """True when the run root fell back to a markerless cwd.
292
+
293
+ The shipped .mcp.json default (DESIGN_PLAYBOOK_RUN_ROOT=".") makes the
294
+ server resolve artifacts under its process cwd; in a host workspace that
295
+ cwd is the repo root, so captures silently land outside the run tree.
296
+ """
297
+ configured = os.environ.get(RUN_ROOT_ENV)
298
+ if configured and configured != ".":
299
+ return False
300
+ root = Path.cwd().resolve()
301
+ return not any((root / marker).is_file() for marker in _RUN_MARKERS)
302
+
303
+
274
304
  def _run_root() -> Path:
275
305
  configured = os.environ.get(RUN_ROOT_ENV)
276
306
  if not configured or configured == ".":
277
- # cwd-relative default silently mis-roots multi-run workspaces (the
278
- # root .mcp.json ships DESIGN_PLAYBOOK_RUN_ROOT="."). Warn only when
279
- # cwd does not look like a run dir (no run marker file) — the shipped
280
- # default resolving to a real run dir is correct usage, not a
281
- # misconfig — and only once per process to avoid per-capture spam.
307
+ # Warn only when cwd does not look like a run dir (no run marker
308
+ # file) — the shipped default resolving to a real run dir is correct
309
+ # usage, not a misconfig — and only once per process to avoid
310
+ # per-capture spam.
282
311
  root = Path.cwd().resolve()
283
312
  global _warned_run_root
284
- if not _warned_run_root and not any(
285
- (root / marker).is_file() for marker in _RUN_MARKERS
286
- ):
313
+ if not _warned_run_root and _run_root_misrooted():
287
314
  _warned_run_root = True
288
315
  _log(
289
316
  "WARNING: DESIGN_PLAYBOOK_RUN_ROOT is unset or '.' "
@@ -23,6 +23,11 @@ consume it instead of mirroring the escape classes. The ``evidence/``
23
23
  operations remain the ADR-0026 contract surface - same reason codes, same
24
24
  existence timing - now expressed as specializations of the one resolver.
25
25
 
26
+ ADR-0044 adds the Diagnostic export write boundary as a third specialization:
27
+ ``trial_export_write_target`` confines one bare filename under
28
+ ``<run_root>/trial-export/`` (the trial-export subtree), with the same
29
+ reason-code discipline and TOCTOU limit.
30
+
26
31
  Threat-model limit (ADR-0026, explicit): this module resolves and validates
27
32
  the path; it does NOT perform the write. Path resolution alone cannot close
28
33
  the TOCTOU gap - a concurrent untrusted filesystem actor that replaces a
@@ -50,6 +55,9 @@ REASON_RESOLUTION_FAILURE = "resolution_failure"
50
55
  REASON_CANONICAL_ESCAPE = "canonical_escape"
51
56
  REASON_SYMLINK_ESCAPE = "symlink_escape"
52
57
  REASON_NOT_REGULAR_FILE = "not_regular_file"
58
+ # Trial-export targets (ADR-0044) accept bare filenames only; a name that
59
+ # carries any separator or reserved form is rejected before resolution.
60
+ REASON_RESERVED_NAME = "reserved_name"
53
61
 
54
62
  # Every resolution-time escape reason (the classes the ADR requires both
55
63
  # operations to reject at resolution time). The Provider treats all of these
@@ -203,3 +211,86 @@ def read_artifact(artifact_path: str, run_root: Path) -> ContainmentResult:
203
211
  must not bind a directory or a missing path).
204
212
  """
205
213
  return _resolve(artifact_path, run_root, require_existing_file=True)
214
+
215
+
216
+ # The Diagnostic export write boundary (ADR-0044): one subtree, sibling of
217
+ # evidence/, owned here so the export transaction cannot disagree with the
218
+ # one containment authority on where trial exports may land.
219
+ TRIAL_EXPORT_SUBDIR = "trial-export"
220
+
221
+ # Names the export subtree may never carry. ``manifest.jsonl`` is reserved
222
+ # across the run tree (the Evidence Manifest authority); the current-directory
223
+ # name is a no-op write and is refused as malformed rather than silently
224
+ # permitted.
225
+ _TRIAL_EXPORT_RESERVED_NAMES = frozenset({"manifest.jsonl", "", ".", ".."})
226
+
227
+ # Win32 name quirks that CreateFile folds but Path.resolve does not: a
228
+ # trailing dot or space vanishes on write (the on-disk name would diverge
229
+ # from the reviewed one), and the reserved device names are never regular
230
+ # files. The export writes exactly the reviewed pair, so both classes are
231
+ # rejected as reserved.
232
+ _WIN32_DEVICE_NAMES = frozenset(
233
+ {"CON", "PRN", "AUX", "NUL",
234
+ *(f"COM{i}" for i in range(1, 10)),
235
+ *(f"LPT{i}" for i in range(1, 10))},
236
+ )
237
+
238
+
239
+ def trial_export_write_target(filename: str, run_root: Path) -> ContainmentResult:
240
+ """Resolve a Diagnostic export write target under ``trial-export/``.
241
+
242
+ ``filename`` must be a bare filename - no directory separators (native,
243
+ POSIX, or Windows), no drive form, no ``..`` segment, and not one of the
244
+ reserved names, Win32 fold forms (trailing dot or space), or reserved
245
+ device stems - anything the platform would write under a different name
246
+ than the one reviewed. Same resolution-time escape
247
+ rejection and TOCTOU limit as every operation in this module; the
248
+ transaction performs the staged write and rollback around this resolution.
249
+ """
250
+ if not isinstance(filename, str) or filename == "":
251
+ return ContainmentResult(None, REASON_RESERVED_NAME)
252
+ if filename in _TRIAL_EXPORT_RESERVED_NAMES:
253
+ return ContainmentResult(None, REASON_RESERVED_NAME)
254
+ # Bare-filename precondition: any separator, drive, or traversal form
255
+ # fails before the generic resolver can even see it.
256
+ if (
257
+ PurePosixPath(filename).is_absolute()
258
+ or PureWindowsPath(filename).is_absolute()
259
+ or "/" in filename
260
+ or "\\" in filename
261
+ or any(part == ".." for part in PurePosixPath(filename).parts)
262
+ or any(part == ".." for part in PureWindowsPath(filename).parts)
263
+ or ":" in filename
264
+ ):
265
+ return ContainmentResult(None, REASON_ABSOLUTE_PATH)
266
+ # Win32 fold classes: a trailing dot/space or a reserved device stem
267
+ # would make the on-disk name differ from the reviewed name.
268
+ if filename != filename.rstrip(" ."):
269
+ return ContainmentResult(None, REASON_RESERVED_NAME)
270
+ if filename.split(".", 1)[0].upper() in _WIN32_DEVICE_NAMES:
271
+ return ContainmentResult(None, REASON_RESERVED_NAME)
272
+ # The boundary subtree itself must be a real child of the run root: a
273
+ # ``trial-export`` symlink pointing outside the run root would make the
274
+ # generic under-boundary check pass while writing outside the selected
275
+ # run, so the resolved boundary is required to stay inside the resolved
276
+ # run root before anything else is resolved against it.
277
+ try:
278
+ resolved_root = run_root.resolve(strict=False)
279
+ boundary = (run_root / TRIAL_EXPORT_SUBDIR).resolve(strict=False)
280
+ Path(os.path.realpath(boundary)).relative_to(
281
+ Path(os.path.realpath(resolved_root))
282
+ )
283
+ except (OSError, ValueError):
284
+ return ContainmentResult(None, REASON_SYMLINK_ESCAPE)
285
+ result = _resolve_candidate(
286
+ run_root,
287
+ f"{TRIAL_EXPORT_SUBDIR}/{filename}",
288
+ run_root / TRIAL_EXPORT_SUBDIR,
289
+ require_existing_file=False,
290
+ )
291
+ # The generic resolver also folds "." segments; a name like "a/." or
292
+ # "a.." is a file name here, but a name that Path normalizes to
293
+ # something other than itself inside the subtree must not pass.
294
+ if result.ok and result.path is not None and result.path.name != filename:
295
+ return ContainmentResult(None, REASON_RESERVED_NAME)
296
+ return result
@@ -32,7 +32,8 @@ def _tool_schema() -> dict[str, Any]:
32
32
  "schemaVersion=1 plus explicit viewport, apply deterministic freeze "
33
33
  "by default, write one artifact (screenshot / a11y tree / "
34
34
  "interaction trace). Returns capture result only (artifact, "
35
- "observed_state, result, error, written_path, request) — never "
35
+ "observed_state, result, error, written_path, request; plus a "
36
+ "warnings list when the run root looks misconfigured) — never "
36
37
  "writes manifest; never judges criteria."
37
38
  ),
38
39
  "inputSchema": {