design-playbook 0.7.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 (47) hide show
  1. package/LICENSE +28 -0
  2. package/NOTICE +37 -0
  3. package/README.md +143 -0
  4. package/commands/design-io.md +8 -0
  5. package/commands/ui-review.md +8 -0
  6. package/commands/ux-spec.md +8 -0
  7. package/mcp/__init__.py +0 -0
  8. package/mcp/_transport.py +242 -0
  9. package/mcp/evidence/README.md +40 -0
  10. package/mcp/evidence/__init__.py +0 -0
  11. package/mcp/evidence/server.py +450 -0
  12. package/mcp/evidence/test_server_stdio.py +645 -0
  13. package/mcp/preview/__init__.py +0 -0
  14. package/mcp/preview/browser.py +661 -0
  15. package/mcp/preview/confirm.py +255 -0
  16. package/mcp/preview/control.py +1293 -0
  17. package/mcp/preview/i18n.py +162 -0
  18. package/mcp/preview/server.py +126 -0
  19. package/mcp/preview/test_browser_control.py +663 -0
  20. package/mcp/preview/test_server_stdio.py +630 -0
  21. package/mcp/preview/test_transaction.py +436 -0
  22. package/mcp/preview/transaction.py +536 -0
  23. package/mcp/preview/util.py +19 -0
  24. package/mcp/test_transport.py +39 -0
  25. package/package.json +42 -0
  26. package/skills/craft-guard/SKILL.md +59 -0
  27. package/skills/craft-guard/references/craft.md +29 -0
  28. package/skills/craft-guard/references/detectors.md +124 -0
  29. package/skills/design-baseline/SKILL.md +134 -0
  30. package/skills/design-baseline/agents/openai.yaml +4 -0
  31. package/skills/design-baseline/references/design-template.md +73 -0
  32. package/skills/design-baseline/references/extraction-guidance.md +39 -0
  33. package/skills/design-baseline/scripts/design_baseline.py +780 -0
  34. package/skills/design-playbook/SKILL.md +219 -0
  35. package/skills/native-craft/SKILL.md +59 -0
  36. package/skills/native-craft/references/native-feel.md +79 -0
  37. package/skills/reference-intake/SKILL.md +86 -0
  38. package/skills/reference-intake/references/contract-template.md +82 -0
  39. package/skills/ui-evaluator/SKILL.md +110 -0
  40. package/skills/ui-evaluator/references/rubric.md +45 -0
  41. package/skills/ui-picker/SKILL.md +63 -0
  42. package/skills/ui-picker/references/components.md +31 -0
  43. package/skills/ui-picker/references/design.md +21 -0
  44. package/skills/ui-picker/references/domain.md +26 -0
  45. package/skills/ui-picker/references/template.md +24 -0
  46. package/skills/ux-spec/SKILL.md +51 -0
  47. package/skills/ux-spec/references/spec-template.md +43 -0
@@ -0,0 +1,780 @@
1
+ #!/usr/bin/env python3
2
+ """Prepare, confirm, and verify a project-owned DESIGN.md baseline.
3
+
4
+ The state file is a cache, not authority. Every public operation resolves paths
5
+ against the supplied project root and ``verify`` re-hashes both the selected
6
+ baseline and its first-party sources before returning a downstream binding.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ import tempfile
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any, Iterable
21
+
22
+
23
+ SCHEMA = "design-baseline/v1"
24
+ STATE_RELATIVE = Path("design-baseline/state.json")
25
+ EVIDENCE_RELATIVE = Path("design-baseline/evidence.json")
26
+ DRAFT_RELATIVE = Path("design-baseline/DESIGN.draft.md")
27
+ CANDIDATES = (Path("DESIGN.md"), Path(".stitch/DESIGN.md"))
28
+ # Provenance-minimal gate: an existing project DESIGN.md only has to carry
29
+ # verifiable source provenance (path + SHA-256) to be bound. The other
30
+ # section names are draft-template guidance (references/design-template.md),
31
+ # not a structural contract imposed on adopted baselines (ADR-0012). Imposing
32
+ # the full 9-section template on hand-written existing baselines falsely
33
+ # rejected them and triggered needless regeneration.
34
+ REQUIRED_SECTIONS = (
35
+ "Source Evidence & Confidence",
36
+ )
37
+
38
+ _SKIP_DIRECTORIES = {
39
+ ".git",
40
+ ".scratch",
41
+ ".next",
42
+ ".nuxt",
43
+ ".svelte-kit",
44
+ "build",
45
+ "coverage",
46
+ "dist",
47
+ "node_modules",
48
+ "out",
49
+ "target",
50
+ "vendor",
51
+ }
52
+ _FRONTEND_SUFFIXES = {
53
+ ".css",
54
+ ".html",
55
+ ".jsx",
56
+ ".less",
57
+ ".pcss",
58
+ ".sass",
59
+ ".scss",
60
+ ".svelte",
61
+ ".tsx",
62
+ ".vue",
63
+ }
64
+ _CONFIG_NAMES = {
65
+ "tailwind.config.js",
66
+ "tailwind.config.cjs",
67
+ "tailwind.config.mjs",
68
+ "tailwind.config.ts",
69
+ "theme.js",
70
+ "theme.json",
71
+ "theme.ts",
72
+ "tokens.json",
73
+ }
74
+ _MAX_SOURCES = 32
75
+ _MAX_SOURCE_BYTES = 1024 * 1024
76
+ # Cap on state.json / DESIGN.md text reads (issue M2): these files live inside
77
+ # the project tree and are attacker-influenced; an unbounded read_text lets a
78
+ # planted multi-GB file OOM the verifying process. 4 MiB is far above any
79
+ # legitimate baseline/state payload.
80
+ _MAX_DOC_BYTES = 4 * 1024 * 1024
81
+
82
+
83
+ class BaselineError(RuntimeError):
84
+ """Raised when a baseline cannot be safely prepared or verified."""
85
+
86
+
87
+ def _utc_now() -> str:
88
+ return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
89
+
90
+
91
+ def _sha256(path: Path) -> str:
92
+ digest = hashlib.sha256()
93
+ with path.open("rb") as handle:
94
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
95
+ digest.update(chunk)
96
+ return digest.hexdigest()
97
+
98
+
99
+ def _read_text_capped(path: Path, label: str) -> str:
100
+ """Read a project-local text file with a hard size ceiling.
101
+
102
+ state.json and DESIGN.md are attacker-influenced (writable by anyone with
103
+ repo access); an unbounded ``read_text`` lets a planted multi-GB file OOM
104
+ the verifying process (issue M2). Raise ``BaselineError`` on overflow.
105
+ """
106
+ try:
107
+ size = path.stat().st_size
108
+ except OSError as error:
109
+ raise BaselineError(f"{label} cannot be sized: {error}") from error
110
+ if size > _MAX_DOC_BYTES:
111
+ raise BaselineError(f"{label} exceeds {_MAX_DOC_BYTES} bytes")
112
+ try:
113
+ return path.read_text(encoding="utf-8-sig")
114
+ except (OSError, UnicodeError) as error:
115
+ raise BaselineError(f"{label} cannot be read: {error}") from error
116
+
117
+
118
+ def _relative(path: Path, root: Path) -> str:
119
+ return path.relative_to(root).as_posix()
120
+
121
+
122
+ def _inside(path: Path, root: Path) -> bool:
123
+ try:
124
+ path.relative_to(root)
125
+ except ValueError:
126
+ return False
127
+ return True
128
+
129
+
130
+ def _roots(project_root: Path | str, run_root: Path | str) -> tuple[Path, Path]:
131
+ project = Path(project_root).resolve()
132
+ run = Path(run_root).resolve()
133
+ if not project.is_dir():
134
+ raise BaselineError(f"project root is not a directory: {project}")
135
+ if not _inside(run, project):
136
+ raise BaselineError(f"run root escapes project root: {run}")
137
+ return project, run
138
+
139
+
140
+ def _atomic_write_text(path: Path, content: str) -> None:
141
+ path.parent.mkdir(parents=True, exist_ok=True)
142
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
143
+ temporary = Path(temporary_name)
144
+ try:
145
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
146
+ handle.write(content)
147
+ handle.flush()
148
+ os.fsync(handle.fileno())
149
+ os.replace(temporary, path)
150
+ finally:
151
+ if temporary.exists():
152
+ temporary.unlink()
153
+
154
+
155
+ def _atomic_write_json(path: Path, value: dict[str, Any]) -> None:
156
+ _atomic_write_text(path, json.dumps(value, indent=2, ensure_ascii=False) + "\n")
157
+
158
+
159
+ def _candidate_files(project: Path) -> list[Path]:
160
+ candidates: list[Path] = []
161
+ for relative in CANDIDATES:
162
+ candidate = project / relative
163
+ if candidate.is_symlink():
164
+ resolved = candidate.resolve()
165
+ if not _inside(resolved, project):
166
+ raise BaselineError(f"baseline candidate escapes project root: {relative.as_posix()}")
167
+ raise BaselineError(f"baseline candidate must not be a symlink: {relative.as_posix()}")
168
+ if candidate.is_file():
169
+ resolved = candidate.resolve()
170
+ if not _inside(resolved, project):
171
+ raise BaselineError(f"baseline candidate escapes project root: {relative.as_posix()}")
172
+ candidates.append(candidate)
173
+ return candidates
174
+
175
+
176
+ def _iter_project_files(project: Path) -> Iterable[Path]:
177
+ for directory, directory_names, file_names in os.walk(project, followlinks=False):
178
+ directory_names[:] = sorted(name for name in directory_names if name not in _SKIP_DIRECTORIES)
179
+ base = Path(directory)
180
+ for name in sorted(file_names):
181
+ path = base / name
182
+ lower_name = name.lower()
183
+ if path.suffix.lower() not in _FRONTEND_SUFFIXES and lower_name not in _CONFIG_NAMES:
184
+ continue
185
+ if path.is_symlink() or not path.is_file():
186
+ continue
187
+ try:
188
+ if path.stat().st_size > _MAX_SOURCE_BYTES:
189
+ continue
190
+ except OSError:
191
+ continue
192
+ resolved = path.resolve()
193
+ if _inside(resolved, project):
194
+ yield path
195
+
196
+
197
+ def _collect_sources(project: Path) -> list[Path]:
198
+ # Walk order is already deterministic (sorted dirs/names). Cap at
199
+ # _MAX_SOURCES; no keyword ranking — ranking was speculative and did not
200
+ # affect verify re-hash (ADR-0012 / ponytail review).
201
+ sources: list[Path] = []
202
+ for path in _iter_project_files(project):
203
+ sources.append(path)
204
+ if len(sources) >= _MAX_SOURCES:
205
+ break
206
+ return sources
207
+
208
+
209
+ def _unique(values: Iterable[str], limit: int = 24) -> list[str]:
210
+ result: list[str] = []
211
+ seen: set[str] = set()
212
+ for value in values:
213
+ normalized = value.strip()
214
+ if not normalized or normalized in seen:
215
+ continue
216
+ seen.add(normalized)
217
+ result.append(normalized)
218
+ if len(result) >= limit:
219
+ break
220
+ return result
221
+
222
+
223
+ def _extract_evidence(project: Path, sources: list[Path]) -> dict[str, Any]:
224
+ custom_properties: list[dict[str, str]] = []
225
+ colors: list[str] = []
226
+ fonts: list[str] = []
227
+ spacing: list[str] = []
228
+ radii: list[str] = []
229
+ motion: list[str] = []
230
+ components: list[str] = []
231
+ pages: list[str] = []
232
+ source_records: list[dict[str, str]] = []
233
+
234
+ color_pattern = re.compile(
235
+ r"(?i)(?:#[0-9a-f]{3,8}\b|(?:rgb|hsl|hwb|lab|lch|oklab|oklch)\([^;{}]+\))"
236
+ )
237
+ property_pattern = re.compile(r"--([a-zA-Z0-9_-]+)\s*:\s*([^;{}]+)")
238
+ font_pattern = re.compile(r"(?i)font-family\s*:\s*([^;{}]+)")
239
+
240
+ for path in sources:
241
+ relative = _relative(path, project)
242
+ source_records.append({"path": relative, "sha256": _sha256(path)})
243
+ try:
244
+ text = path.read_text(encoding="utf-8-sig")
245
+ except (OSError, UnicodeError):
246
+ continue
247
+
248
+ for name, value in property_pattern.findall(text):
249
+ record = {"name": f"--{name}", "value": value.strip(), "source": relative}
250
+ custom_properties.append(record)
251
+ lowered = name.lower()
252
+ if any(term in lowered for term in ("color", "background", "surface", "text", "primary", "accent")):
253
+ colors.append(f"--{name}: {value.strip()}")
254
+ if any(term in lowered for term in ("space", "gap", "padding", "margin")):
255
+ spacing.append(f"--{name}: {value.strip()}")
256
+ if any(term in lowered for term in ("radius", "round")):
257
+ radii.append(f"--{name}: {value.strip()}")
258
+ if any(term in lowered for term in ("motion", "duration", "ease", "transition")):
259
+ motion.append(f"--{name}: {value.strip()}")
260
+
261
+ colors.extend(color_pattern.findall(text))
262
+ fonts.extend(font_pattern.findall(text))
263
+
264
+ lowered_relative = relative.lower()
265
+ if any(term in lowered_relative for term in ("component", "primitive", "shared", "/ui/")):
266
+ components.append(relative)
267
+ if any(term in lowered_relative for term in ("page", "route", "screen", "view")):
268
+ pages.append(relative)
269
+
270
+ return {
271
+ "schema": "design-baseline-evidence/v1",
272
+ "project_root": str(project),
273
+ "sources": source_records,
274
+ "tokens": custom_properties[:80],
275
+ "colors": _unique(colors),
276
+ "fonts": _unique(fonts, limit=12),
277
+ "spacing": _unique(spacing, limit=16),
278
+ "radii": _unique(radii, limit=12),
279
+ "motion": _unique(motion, limit=12),
280
+ "components": _unique(components, limit=16),
281
+ "pages": _unique(pages, limit=12),
282
+ }
283
+
284
+
285
+ def _yaml_value(value: str) -> str:
286
+ return json.dumps(value, ensure_ascii=False)
287
+
288
+
289
+ def _token_value(evidence: dict[str, Any], terms: tuple[str, ...], fallback: str) -> str:
290
+ for token in evidence["tokens"]:
291
+ if any(term in token["name"].lower() for term in terms):
292
+ return token["name"]
293
+ if evidence["colors"] and terms == ("primary", "accent"):
294
+ return evidence["colors"][0].split(":", 1)[0]
295
+ return fallback
296
+
297
+
298
+ def _observed_list(values: list[str], empty: str, limit: int = 8) -> list[str]:
299
+ if not values:
300
+ return [f"- [inferred confidence=low] {empty}"]
301
+ return [f"- [observed] `{value}`" for value in values[:limit]]
302
+
303
+
304
+ def _render_draft(project: Path, evidence: dict[str, Any]) -> str:
305
+ project_name = project.name
306
+ background = _token_value(evidence, ("background", "canvas"), "unresolved")
307
+ surface = _token_value(evidence, ("surface", "card", "panel"), "unresolved")
308
+ text = _token_value(evidence, ("text", "foreground"), "unresolved")
309
+ primary = _token_value(evidence, ("primary", "accent"), "unresolved")
310
+ source_paths = [item["path"] for item in evidence["sources"]]
311
+
312
+ lines = [
313
+ "---",
314
+ f"name: {_yaml_value(project_name)}",
315
+ "colors:",
316
+ f" background: {_yaml_value(background)}",
317
+ f" surface: {_yaml_value(surface)}",
318
+ f" text: {_yaml_value(text)}",
319
+ f" primary: {_yaml_value(primary)}",
320
+ "---",
321
+ "",
322
+ f"# Design System: {project_name}",
323
+ "",
324
+ "## Visual Theme & Atmosphere",
325
+ "",
326
+ "- [observed] Existing first-party theme, shared component, and page sources define the current visual baseline.",
327
+ "- [inferred confidence=medium] Preserve the observed token vocabulary, density, and component conventions when adding new surfaces.",
328
+ "",
329
+ "## Color Palette & Roles",
330
+ "",
331
+ *_observed_list(evidence["colors"], "Color roles are not explicit in the inspected sources; confirm them before use."),
332
+ "",
333
+ "## Typography Rules",
334
+ "",
335
+ *_observed_list(evidence["fonts"], "Typography hierarchy is not explicit; infer only from representative rendered surfaces."),
336
+ "- [inferred confidence=medium] Reuse the existing font stack and derive hierarchy from shared components before introducing new sizes.",
337
+ "",
338
+ "## Component Stylings",
339
+ "",
340
+ *_observed_list(evidence["components"], "No shared component path was detected; treat component styling as an unresolved gap."),
341
+ "- [inferred confidence=medium] Prefer existing primitives and variants over page-local replacements.",
342
+ "",
343
+ "## Layout Principles",
344
+ "",
345
+ *_observed_list(evidence["spacing"], "No named spacing tokens were detected; confirm the base spacing rhythm."),
346
+ *[f"- [observed] Representative page: `{path}`" for path in evidence["pages"][:6]],
347
+ "- [inferred confidence=medium] Match the density and alignment rhythm of representative pages.",
348
+ "",
349
+ "## Motion & Interaction",
350
+ "",
351
+ *_observed_list(evidence["motion"], "No motion token was detected; keep transitions restrained until interaction evidence is available."),
352
+ "- [inferred confidence=medium] Preserve visible hover, focus, pressed, loading, and reduced-motion behavior from existing primitives.",
353
+ "",
354
+ "## Accessibility",
355
+ "",
356
+ "- [inferred confidence=medium] Preserve semantic controls, keyboard focus visibility, and non-color state cues present in existing primitives.",
357
+ "- [inferred confidence=low] Contrast, touch targets, text scaling, and reduced-motion behavior require runtime verification.",
358
+ "",
359
+ "## Source Evidence & Confidence",
360
+ "",
361
+ ]
362
+ for source in evidence["sources"]:
363
+ lines.extend(
364
+ [
365
+ f"- [observed] path: `{source['path']}`",
366
+ f" sha256: `{source['sha256']}`",
367
+ " confidence: high",
368
+ ]
369
+ )
370
+ lines.extend(
371
+ [
372
+ "",
373
+ "## Known Gaps & Exceptions",
374
+ "",
375
+ "- [inferred confidence=medium] Semantic intent inferred from implementation must be reviewed before this draft becomes project authority.",
376
+ ]
377
+ )
378
+ if not source_paths:
379
+ lines.append("- [inferred confidence=low] No high-signal first-party frontend source was discovered.")
380
+ if not evidence["radii"]:
381
+ lines.append("- [inferred confidence=low] Shape and corner-radius conventions are unresolved.")
382
+ else:
383
+ lines.extend(f"- [observed] Shape token `{value}`" for value in evidence["radii"][:6])
384
+ return "\n".join(lines).rstrip() + "\n"
385
+
386
+
387
+ def _state_path(run: Path) -> Path:
388
+ return run / STATE_RELATIVE
389
+
390
+
391
+ def _write_state(run: Path, state: dict[str, Any]) -> dict[str, Any]:
392
+ _atomic_write_json(_state_path(run), state)
393
+ return state
394
+
395
+
396
+ def _normalize_heading(value: str) -> str:
397
+ value = re.sub(r"^\d+[.)]\s*", "", value.strip().lower())
398
+ return re.sub(r"\s+", " ", value)
399
+
400
+
401
+ def _parse_sections(text: str) -> dict[str, str]:
402
+ matches = list(re.finditer(r"(?m)^##\s+(?:\d+[.)]\s*)?(.+?)\s*$", text))
403
+ sections: dict[str, str] = {}
404
+ for index, match in enumerate(matches):
405
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
406
+ sections[_normalize_heading(match.group(1))] = text[match.end() : end].strip()
407
+ return sections
408
+
409
+
410
+ def _safe_relative_file(root: Path, relative: str, label: str) -> Path:
411
+ if not isinstance(relative, str) or not relative or "\\" in relative:
412
+ raise BaselineError(f"{label} must be a normalized project-relative path: {relative!r}")
413
+ # Reject NUL / control chars up front: PurePath accepts them at construction
414
+ # and only fails at lstat/open with a raw ValueError (issue L3), which
415
+ # escapes BaselineError handling and surfaces a traceback to the agent.
416
+ if any(ord(ch) < 0x20 or ch == "\x7f" for ch in relative):
417
+ raise BaselineError(f"{label} contains control characters: {relative!r}")
418
+ path_value = Path(relative)
419
+ if path_value.is_absolute() or ".." in path_value.parts:
420
+ raise BaselineError(f"{label} escapes project root: {relative}")
421
+ path = root / path_value
422
+ if path.is_symlink():
423
+ raise BaselineError(f"{label} must not be a symlink: {relative}")
424
+ resolved = path.resolve()
425
+ if not _inside(resolved, root):
426
+ raise BaselineError(f"{label} escapes project root: {relative}")
427
+ if not resolved.is_file():
428
+ raise BaselineError(f"{label} does not exist: {relative}")
429
+ return resolved
430
+
431
+
432
+ def _parse_sources(section: str) -> list[dict[str, str]]:
433
+ entries: list[dict[str, str]] = []
434
+ current: dict[str, str] | None = None
435
+ path_pattern = re.compile(
436
+ r"^\s*-\s+(?:\[observed\]\s+)?path:\s*[`'\"]?(.+?)[`'\"]?\s*$"
437
+ )
438
+ hash_pattern = re.compile(r"^\s+sha256:\s*[`'\"]?([0-9a-f]{64})[`'\"]?\s*$")
439
+ for line in section.splitlines():
440
+ path_match = path_pattern.match(line)
441
+ if path_match:
442
+ if current is not None:
443
+ entries.append(current)
444
+ current = {"path": path_match.group(1).strip()}
445
+ continue
446
+ hash_match = hash_pattern.match(line)
447
+ if hash_match and current is not None:
448
+ current["sha256"] = hash_match.group(1)
449
+ if current is not None:
450
+ entries.append(current)
451
+ return entries
452
+
453
+
454
+ def _validate_claim_labels(sections: dict[str, str]) -> None:
455
+ claim_pattern = re.compile(
456
+ r"^\s*-\s+\[(?:observed|inferred confidence=(?:high|medium|low))\]\s+\S"
457
+ )
458
+ for heading in REQUIRED_SECTIONS:
459
+ content = sections[_normalize_heading(heading)]
460
+ for line in content.splitlines():
461
+ if re.match(r"^\s*-\s+", line) and not claim_pattern.match(line):
462
+ raise BaselineError(f"unlabelled design claim in {heading}: {line.strip()}")
463
+
464
+
465
+ def _validate_source_records(project: Path, sources: Any) -> list[dict[str, str]]:
466
+ if not isinstance(sources, list) or not sources:
467
+ raise BaselineError("baseline must contain at least one provenance source")
468
+ normalized: list[dict[str, str]] = []
469
+ seen: set[str] = set()
470
+ for source in sources:
471
+ if not isinstance(source, dict):
472
+ raise BaselineError("baseline source entry must be an object")
473
+ relative = source.get("path")
474
+ expected = source.get("sha256")
475
+ if not isinstance(relative, str) or not isinstance(expected, str):
476
+ raise BaselineError("baseline source requires path and sha256 strings")
477
+ if not re.fullmatch(r"[0-9a-f]{64}", expected):
478
+ raise BaselineError(f"invalid source SHA-256 for {relative}")
479
+ if relative in seen:
480
+ raise BaselineError(f"duplicate baseline source: {relative}")
481
+ seen.add(relative)
482
+ source_path = _safe_relative_file(project, relative, "baseline source")
483
+ actual = _sha256(source_path)
484
+ if actual != expected:
485
+ raise BaselineError(f"baseline source changed: {relative}")
486
+ normalized.append({"path": relative, "sha256": expected})
487
+ return normalized
488
+
489
+
490
+ def _load_state(project: Path, run: Path) -> dict[str, Any]:
491
+ state_path = _state_path(run)
492
+ if state_path.is_symlink() or not state_path.is_file():
493
+ raise BaselineError(f"baseline state does not exist: {state_path}")
494
+ try:
495
+ state = json.loads(_read_text_capped(state_path, "baseline state"))
496
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
497
+ raise BaselineError(f"cannot read baseline state: {error}") from error
498
+ if not isinstance(state, dict) or state.get("schema") != SCHEMA:
499
+ raise BaselineError("unsupported baseline state schema")
500
+ if state.get("project_root") != str(project):
501
+ raise BaselineError("baseline state is bound to a different project root")
502
+ return state
503
+
504
+
505
+ def _candidate_snapshot(project: Path, candidates: list[Path]) -> dict[str, str]:
506
+ return {_relative(path, project): _sha256(path) for path in candidates}
507
+
508
+
509
+ def _assert_candidate_snapshot(project: Path, state: dict[str, Any]) -> None:
510
+ expected = state.get("candidate_sha256", {})
511
+ if not isinstance(expected, dict) or not all(
512
+ isinstance(path, str) and isinstance(digest, str) for path, digest in expected.items()
513
+ ):
514
+ raise BaselineError("invalid candidate snapshot in baseline state")
515
+ actual = _candidate_snapshot(project, _candidate_files(project))
516
+ if actual != expected:
517
+ raise BaselineError("baseline candidates changed after preparation; run prepare again")
518
+
519
+
520
+ def prepare(project_root: Path | str, run_root: Path | str) -> dict[str, Any]:
521
+ """Discover a valid baseline or generate a provenance-backed run-local draft."""
522
+
523
+ project, run = _roots(project_root, run_root)
524
+ state_directory = run / "design-baseline"
525
+ state_directory.mkdir(parents=True, exist_ok=True)
526
+ candidates = _candidate_files(project)
527
+ candidate_names = [_relative(path, project) for path in candidates]
528
+
529
+ if len(candidates) > 1:
530
+ hashes = _candidate_snapshot(project, candidates)
531
+ if len(set(hashes.values())) > 1:
532
+ return _write_state(
533
+ run,
534
+ {
535
+ "schema": SCHEMA,
536
+ "status": "ambiguous",
537
+ "project_root": str(project),
538
+ "baseline": None,
539
+ "draft": None,
540
+ "sources": [],
541
+ "decision": None,
542
+ "candidates": candidate_names,
543
+ "candidate_sha256": hashes,
544
+ },
545
+ )
546
+
547
+ # Existing candidates are validated by the same strict parser used by
548
+ # verify(). An incomplete candidate remains untouched while a replacement
549
+ # proposal is generated in the run directory.
550
+ if candidates:
551
+ selected = project / CANDIDATES[0] if (project / CANDIDATES[0]) in candidates else candidates[0]
552
+ try:
553
+ sources = _validate_baseline_document(selected, project)
554
+ except BaselineError:
555
+ sources = None
556
+ if sources is not None:
557
+ state = {
558
+ "schema": SCHEMA,
559
+ "status": "ready",
560
+ "project_root": str(project),
561
+ "baseline": {
562
+ "path": _relative(selected, project),
563
+ "sha256": _sha256(selected),
564
+ "origin": "existing",
565
+ },
566
+ "draft": None,
567
+ "sources": sources,
568
+ "decision": {"kind": "existing", "confirmed_at": _utc_now()},
569
+ }
570
+ return _write_state(run, state)
571
+
572
+ source_files = _collect_sources(project)
573
+ evidence = _extract_evidence(project, source_files)
574
+ draft_text = _render_draft(project, evidence)
575
+ evidence_path = run / EVIDENCE_RELATIVE
576
+ draft_path = run / DRAFT_RELATIVE
577
+ _atomic_write_json(evidence_path, evidence)
578
+ _atomic_write_text(draft_path, draft_text)
579
+ return _write_state(
580
+ run,
581
+ {
582
+ "schema": SCHEMA,
583
+ "status": "needs_confirmation",
584
+ "project_root": str(project),
585
+ "baseline": None,
586
+ "draft": {"path": DRAFT_RELATIVE.as_posix(), "sha256": _sha256(draft_path)},
587
+ "sources": evidence["sources"],
588
+ "decision": None,
589
+ "candidates": candidate_names,
590
+ "candidate_sha256": _candidate_snapshot(project, candidates),
591
+ },
592
+ )
593
+
594
+
595
+ def _validate_baseline_document(path: Path, project: Path) -> list[dict[str, str]]:
596
+ try:
597
+ text = _read_text_capped(path, "baseline document").replace("\r\n", "\n")
598
+ except (OSError, UnicodeError) as error:
599
+ raise BaselineError(f"cannot read baseline document: {error}") from error
600
+
601
+ frontmatter = re.match(r"\A---\s*\n(.*?)\n---\s*(?:\n|\Z)", text, re.DOTALL)
602
+ if frontmatter is None:
603
+ raise BaselineError("baseline is missing YAML frontmatter")
604
+ metadata = frontmatter.group(1)
605
+ if not re.search(r"(?m)^name:\s*\S.*$", metadata):
606
+ raise BaselineError("baseline frontmatter is missing name")
607
+ colors = re.search(r"(?ms)^colors:\s*\n((?:[ \t]+[^\n]*(?:\n|$))*)", metadata)
608
+ if colors is None or not re.search(r"(?m)^\s{2,}[A-Za-z0-9_-]+:\s*\S.*$", colors.group(1)):
609
+ raise BaselineError("baseline frontmatter is missing color roles")
610
+
611
+ sections = _parse_sections(text)
612
+ for heading in REQUIRED_SECTIONS:
613
+ key = _normalize_heading(heading)
614
+ if key not in sections or not re.sub(r"[`*_#>\-\s]", "", sections[key]):
615
+ raise BaselineError(f"baseline is missing content for {heading}")
616
+ _validate_claim_labels(sections)
617
+ sources = _parse_sources(sections[_normalize_heading("Source Evidence & Confidence")])
618
+ return _validate_source_records(project, sources)
619
+
620
+
621
+ def confirm(
622
+ project_root: Path | str,
623
+ run_root: Path | str,
624
+ decision: str,
625
+ reason: str | None = None,
626
+ ) -> dict[str, Any]:
627
+ """Confirm a generated draft or explicitly waive the baseline gate."""
628
+
629
+ project, run = _roots(project_root, run_root)
630
+ state = _load_state(project, run)
631
+ normalized_decision = decision.strip().lower()
632
+ if state.get("status") != "needs_confirmation":
633
+ raise BaselineError(f"baseline state cannot be confirmed from status: {state.get('status')}")
634
+
635
+ _assert_candidate_snapshot(project, state)
636
+ if normalized_decision == "waive":
637
+ normalized_reason = reason.strip() if isinstance(reason, str) else ""
638
+ if not normalized_reason:
639
+ raise BaselineError("waiver requires a non-empty reason")
640
+ _validate_source_records(project, state.get("sources"))
641
+ state["status"] = "waived"
642
+ state["baseline"] = None
643
+ state["decision"] = {
644
+ "kind": "waived",
645
+ "reason": normalized_reason,
646
+ "confirmed_at": _utc_now(),
647
+ }
648
+ _write_state(run, state)
649
+ return verify(project, run)
650
+
651
+ if normalized_decision != "accept":
652
+ raise BaselineError(f"unsupported baseline decision: {decision}")
653
+
654
+ draft = state.get("draft")
655
+ if not isinstance(draft, dict) or draft.get("path") != DRAFT_RELATIVE.as_posix():
656
+ raise BaselineError("baseline state does not bind the expected draft")
657
+ expected_draft_hash = draft.get("sha256")
658
+ if not isinstance(expected_draft_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_draft_hash):
659
+ raise BaselineError("baseline draft has an invalid SHA-256 binding")
660
+ draft_path = _safe_relative_file(run, DRAFT_RELATIVE.as_posix(), "baseline draft")
661
+ if _sha256(draft_path) != expected_draft_hash:
662
+ raise BaselineError("baseline draft changed after preparation; run prepare again")
663
+ sources = _validate_baseline_document(draft_path, project)
664
+ if sources != state.get("sources"):
665
+ raise BaselineError("baseline draft provenance does not match prepared state")
666
+
667
+ canonical = project / CANDIDATES[0]
668
+ if canonical.is_symlink():
669
+ raise BaselineError("canonical DESIGN.md must not be a symlink")
670
+ draft_text = _read_text_capped(draft_path, "baseline draft")
671
+ _atomic_write_text(canonical, draft_text)
672
+ # Post-write TOCTOU hardening (issue M1): between the pre-write symlink
673
+ # check and os.replace, a concurrent writer could swap DESIGN.md for a
674
+ # symlink escaping the project root. Re-assert the canonical entry is a
675
+ # regular file resolving inside the project before trusting the binding;
676
+ # the subsequent verify() then re-hashes it as the durable authority.
677
+ if canonical.is_symlink() or not canonical.is_file():
678
+ raise BaselineError("canonical DESIGN.md was replaced during write; re-run prepare")
679
+ if not _inside(canonical.resolve(), project):
680
+ raise BaselineError("canonical DESIGN.md escapes project root after write")
681
+ state["status"] = "ready"
682
+ state["baseline"] = {
683
+ "path": CANDIDATES[0].as_posix(),
684
+ "sha256": _sha256(canonical),
685
+ "origin": "generated",
686
+ }
687
+ state["sources"] = sources
688
+ state["decision"] = {"kind": "accepted", "confirmed_at": _utc_now()}
689
+ state["candidate_sha256"] = _candidate_snapshot(project, _candidate_files(project))
690
+ _write_state(run, state)
691
+ return verify(project, run)
692
+
693
+
694
+ def verify(project_root: Path | str, run_root: Path | str) -> dict[str, Any]:
695
+ """Return a verified binding for downstream consumers."""
696
+
697
+ project, run = _roots(project_root, run_root)
698
+ state = _load_state(project, run)
699
+ status = state.get("status")
700
+ decision = state.get("decision")
701
+
702
+ if status == "waived":
703
+ if (
704
+ not isinstance(decision, dict)
705
+ or decision.get("kind") != "waived"
706
+ or not isinstance(decision.get("reason"), str)
707
+ or not decision["reason"].strip()
708
+ or not isinstance(decision.get("confirmed_at"), str)
709
+ ):
710
+ raise BaselineError("invalid baseline waiver decision")
711
+ _validate_source_records(project, state.get("sources"))
712
+ return state
713
+
714
+ if status != "ready":
715
+ raise BaselineError(f"baseline is not ready: {status}")
716
+ if not isinstance(decision, dict) or decision.get("kind") not in {"accepted", "existing"}:
717
+ raise BaselineError("ready baseline lacks a valid decision")
718
+
719
+ baseline = state.get("baseline")
720
+ if not isinstance(baseline, dict):
721
+ raise BaselineError("ready baseline lacks a binding")
722
+ relative = baseline.get("path")
723
+ expected_hash = baseline.get("sha256")
724
+ origin = baseline.get("origin")
725
+ allowed_paths = {candidate.as_posix() for candidate in CANDIDATES}
726
+ if relative not in allowed_paths:
727
+ raise BaselineError(f"unsupported baseline path: {relative}")
728
+ if not isinstance(expected_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_hash):
729
+ raise BaselineError("invalid baseline SHA-256 binding")
730
+ if origin not in {"existing", "generated"}:
731
+ raise BaselineError(f"invalid baseline origin: {origin}")
732
+ if origin == "generated" and decision.get("kind") != "accepted":
733
+ raise BaselineError("generated baseline lacks explicit acceptance")
734
+
735
+ candidates = _candidate_files(project)
736
+ candidate_hashes = _candidate_snapshot(project, candidates)
737
+ if len(set(candidate_hashes.values())) > 1:
738
+ raise BaselineError("project has conflicting DESIGN.md candidates")
739
+ if relative not in candidate_hashes:
740
+ raise BaselineError(f"bound baseline candidate does not exist: {relative}")
741
+ baseline_path = _safe_relative_file(project, relative, "baseline")
742
+ if _sha256(baseline_path) != expected_hash:
743
+ raise BaselineError(f"baseline changed after binding: {relative}")
744
+ document_sources = _validate_baseline_document(baseline_path, project)
745
+ state_sources = _validate_source_records(project, state.get("sources"))
746
+ if document_sources != state_sources:
747
+ raise BaselineError("baseline provenance does not match bound state")
748
+ return state
749
+
750
+
751
+ def main(argv: list[str] | None = None) -> int:
752
+ parser = argparse.ArgumentParser(description=__doc__)
753
+ subparsers = parser.add_subparsers(dest="command", required=True)
754
+ for command in ("prepare", "verify"):
755
+ subparser = subparsers.add_parser(command)
756
+ subparser.add_argument("project_root", type=Path)
757
+ subparser.add_argument("run_root", type=Path)
758
+ confirm_parser = subparsers.add_parser("confirm")
759
+ confirm_parser.add_argument("project_root", type=Path)
760
+ confirm_parser.add_argument("run_root", type=Path)
761
+ confirm_parser.add_argument("--decision", required=True, choices=("accept", "waive"))
762
+ confirm_parser.add_argument("--reason")
763
+ args = parser.parse_args(argv)
764
+
765
+ try:
766
+ if args.command == "prepare":
767
+ result = prepare(args.project_root, args.run_root)
768
+ elif args.command == "confirm":
769
+ result = confirm(args.project_root, args.run_root, args.decision, args.reason)
770
+ else:
771
+ result = verify(args.project_root, args.run_root)
772
+ except BaselineError as error:
773
+ print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
774
+ return 2
775
+ print(json.dumps(result, indent=2, ensure_ascii=False))
776
+ return 0
777
+
778
+
779
+ if __name__ == "__main__":
780
+ sys.exit(main())