design-playbook 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/codex/AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- generated-by design-playbook v0.23.0 -->
1
+ <!-- generated-by design-playbook v0.24.1 -->
2
2
  # design-playbook for Codex
3
3
 
4
4
  ## Install (path of record)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "design-playbook",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
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",
@@ -115,6 +115,16 @@ _TIER2: tuple[AgentRow, ...] = (
115
115
  skills=False,
116
116
  rules_target=".github/copilot-instructions.md",
117
117
  ),
118
+ AgentRow(
119
+ agent="zed",
120
+ tier=2,
121
+ rules=True,
122
+ commands=False,
123
+ mcp_project=True,
124
+ hooks=False,
125
+ skills=False,
126
+ rules_target=".rules + .zed/settings.json",
127
+ ),
118
128
  )
119
129
 
120
130
  # Tier 3 — rules floor (generated AGENTS.md + inline MCP guide).
package/scripts/doctor.py CHANGED
@@ -3,6 +3,9 @@
3
3
 
4
4
  Runs against the installed plugin package root (this file's grandparents),
5
5
  not the monorepo. Reports capability level and concrete repairs.
6
+
7
+ Also reports drifted ``npx design-playbook init <agent>`` artifacts in the
8
+ target repository (report-only; the refresh action is re-running init).
6
9
  """
7
10
  from __future__ import annotations
8
11
 
@@ -10,6 +13,7 @@ import argparse
10
13
  import importlib.util
11
14
  import json
12
15
  import os
16
+ import re
13
17
  import sys
14
18
  from pathlib import Path
15
19
 
@@ -21,6 +25,8 @@ from design_playbook.scripts.audit_preferences import ( # noqa: E402
21
25
  effective_plan,
22
26
  resolve_preferences,
23
27
  )
28
+ from design_playbook.scripts.adapter_matrix import MATRIX # noqa: E402
29
+ from design_playbook.scripts.generate_adapter import render_entries # noqa: E402
24
30
 
25
31
  LEVELS = ("ok", "degraded", "broken")
26
32
 
@@ -35,6 +41,239 @@ def _check(name: str, ok: bool, repair: str, *, required: bool = True) -> dict:
35
41
  }
36
42
 
37
43
 
44
+ # ---------------------------------------------------------------------------
45
+ # Adapter lifecycle (CONTEXT.md "Adapter lifecycle check", 2026-09-19).
46
+ # Report-only: drift means re-running `npx design-playbook init <agent>` with
47
+ # the installed package would change the file. Never writes; never blocks.
48
+ # ---------------------------------------------------------------------------
49
+
50
+ _MARKER_RE = re.compile(r"generated-by design-playbook v(\d[^\s\"']*)")
51
+ _MARKER_NORM_RE = re.compile(r"generated-by design-playbook v\d[^\s\"']*")
52
+
53
+ # Namespaced output roots that can hold orphaned generated files, per agent.
54
+ # Kept honest by tests/test_adapter_lifecycle.py: every non-native agent's
55
+ # fresh init must be discovered through this map plus _WHOLE_FILE_CANDIDATES.
56
+ _ORPHAN_SCAN_DIRS: dict[str, tuple[str, ...]] = {
57
+ "codex": (".codex-plugin", "codex"),
58
+ "cursor": (".cursor/rules",),
59
+ "gemini-cli": (".gemini/commands",),
60
+ "windsurf": (".windsurf/rules", ".windsurf/workflows"),
61
+ "github-copilot": (".github/instructions",),
62
+ "zed": (".zed",),
63
+ }
64
+ # Whole-file candidates carrying the generated-by marker outside namespaced
65
+ # dirs (marker-block targets are always re-rendered in place, so they are
66
+ # discovered here rather than via orphan scanning).
67
+ _WHOLE_FILE_CANDIDATES = (
68
+ "AGENTS.md",
69
+ "GEMINI.md",
70
+ ".github/copilot-instructions.md",
71
+ "design-playbook-mcp-setup.md",
72
+ ".rules",
73
+ )
74
+
75
+ _LIMITATIONS = (
76
+ "marker-less JSON merge targets (.mcp.json, opencode.json, "
77
+ "settings.json) are never attributed; re-init may still merge into them"
78
+ )
79
+
80
+
81
+ def _read_text_safe(path: Path) -> str | None:
82
+ try:
83
+ return path.read_text(encoding="utf-8", errors="replace")
84
+ except OSError:
85
+ return None
86
+
87
+
88
+ def _normalize_generation(text: str) -> str:
89
+ """Marker-version and CRLF normalization for byte comparison."""
90
+ return _MARKER_NORM_RE.sub(
91
+ "generated-by design-playbook vNORM", text.replace("\r\n", "\n")
92
+ )
93
+
94
+
95
+ def _iter_orphan_candidates(repo_root: Path):
96
+ """Yield (agent, path, rel) for every file under a namespaced output
97
+ root — the single walk behind both the discovery gate and the orphan
98
+ scan, so _ORPHAN_SCAN_DIRS has exactly one interpretation."""
99
+ for agent, dirs in _ORPHAN_SCAN_DIRS.items():
100
+ for d in dirs:
101
+ base = repo_root / d
102
+ if not base.is_dir():
103
+ continue
104
+ for f in sorted(base.iterdir()):
105
+ if f.is_file():
106
+ yield agent, f, f.relative_to(repo_root).as_posix()
107
+
108
+
109
+ def _lifecycle_findings(repo_root: Path, package_version: str | None) -> dict:
110
+ """Classify generated init artifacts under *repo_root*. Read-only and
111
+ fail-open per agent: a renderer that cannot compute content (malformed
112
+ JSON merge target, undecodable marker file) degrades to a render_error
113
+ finding instead of crashing the doctor report.
114
+
115
+ Only files carrying the generated-by marker are judged; marker-less
116
+ files (user-authored content, marker-less JSON merge targets) are never
117
+ attributed — an unreadable file counts as unattributed for the same
118
+ reason (it cannot be judged as ours).
119
+ """
120
+ files: list[dict] = []
121
+ counts = {
122
+ "drifted": 0,
123
+ "orphaned": 0,
124
+ "clean": 0,
125
+ "absent": 0,
126
+ "unattributed": 0,
127
+ "render_error": 0,
128
+ }
129
+
130
+ def record(agent: str, rel: str, cls: str, marker_version: str | None = None) -> None:
131
+ counts[cls] += 1
132
+ if cls in ("drifted", "orphaned"):
133
+ files.append({
134
+ "agent": agent,
135
+ "path": rel,
136
+ "cls": cls,
137
+ "marker_version": marker_version,
138
+ "repair": (
139
+ f"npx design-playbook init {agent}"
140
+ if cls == "drifted"
141
+ else f"delete {rel} then npx design-playbook init {agent}"
142
+ ),
143
+ })
144
+
145
+ # Discovery gate: without any generated-by marker anywhere there is
146
+ # nothing to attribute — skip the per-agent renders entirely. One walk
147
+ # over the namespaced roots feeds both this gate and the orphan scan.
148
+ orphan_candidates = list(_iter_orphan_candidates(repo_root))
149
+ discovered = False
150
+ for rel in _WHOLE_FILE_CANDIDATES:
151
+ text = _read_text_safe(repo_root / rel)
152
+ if text is not None and _MARKER_RE.search(text):
153
+ discovered = True
154
+ break
155
+ if not discovered:
156
+ for _agent, path, _rel in orphan_candidates:
157
+ text = _read_text_safe(path)
158
+ if text is not None and _MARKER_RE.search(text):
159
+ discovered = True
160
+ break
161
+ if not discovered:
162
+ return {
163
+ "status": "not-initialized",
164
+ "counts": counts,
165
+ "files": [],
166
+ "package_version": package_version,
167
+ "limitations": _LIMITATIONS,
168
+ }
169
+
170
+ # One render pass over all non-native agents, grouped by target path.
171
+ # AGENTS.md is a shared target (opencode + every tier-3 floor agent), so
172
+ # a file is clean when it matches ANY current candidate render; drift is
173
+ # "matches no current renderer", never "differs from one agent's render".
174
+ renders: dict[str, list[tuple[str, str]]] = {}
175
+ rendered_by_agent: dict[str, set[str]] = {}
176
+ render_failed: set[str] = set()
177
+ for row in MATRIX:
178
+ if row.native:
179
+ continue
180
+ try:
181
+ _version, _out_dir, entries = render_entries(row.agent, repo_root)
182
+ except (ValueError, OSError) as exc:
183
+ # Fail-loud renderer inputs (malformed merge JSON, undecodable
184
+ # marker file) degrade to a finding; the other agents still
185
+ # classify and the doctor keeps reporting. No rendered set means
186
+ # the orphan scan must skip this agent too — flagging its
187
+ # marker'd files orphaned would be a false positive.
188
+ counts["render_error"] += 1
189
+ render_failed.add(row.agent)
190
+ files.append({
191
+ "agent": row.agent,
192
+ "path": None,
193
+ "cls": "render_error",
194
+ "marker_version": None,
195
+ "reason": str(exc),
196
+ "repair": (
197
+ f"fix or remove the unreadable config, then "
198
+ f"npx design-playbook init {row.agent}"
199
+ ),
200
+ })
201
+ continue
202
+ rendered_by_agent[row.agent] = {rel for rel, _content in entries}
203
+ for rel, content in entries:
204
+ renders.setdefault(rel, []).append((row.agent, content))
205
+
206
+ for rel in sorted(renders):
207
+ candidates = renders[rel]
208
+ path = repo_root / rel
209
+ if not path.is_file():
210
+ counts["absent"] += 1
211
+ continue
212
+ actual = _read_text_safe(path)
213
+ m = _MARKER_RE.search(actual) if actual is not None else None
214
+ if m is None:
215
+ counts["unattributed"] += 1
216
+ continue
217
+ if any(
218
+ _normalize_generation(content) == _normalize_generation(actual)
219
+ for _agent, content in candidates
220
+ ):
221
+ counts["clean"] += 1
222
+ continue
223
+ agents = [agent for agent, _content in candidates]
224
+ if len(agents) == 1:
225
+ agent: str | None = agents[0]
226
+ repair = f"npx design-playbook init {agent}"
227
+ else:
228
+ agent = None
229
+ repair = (
230
+ f"npx design-playbook init <your-agent> "
231
+ f"({rel} is shared by: {', '.join(agents)})"
232
+ )
233
+ counts["drifted"] += 1
234
+ files.append({
235
+ "agent": agent,
236
+ "path": rel,
237
+ "cls": "drifted",
238
+ "marker_version": m.group(1),
239
+ "repair": repair,
240
+ })
241
+
242
+ for agent, path, rel in orphan_candidates:
243
+ if agent in render_failed or rel in rendered_by_agent.get(agent, set()):
244
+ continue
245
+ text = _read_text_safe(path)
246
+ m = _MARKER_RE.search(text) if text is not None else None
247
+ if m is not None:
248
+ record(agent, rel, "orphaned", m.group(1))
249
+
250
+ return {
251
+ "status": "scanned",
252
+ "counts": counts,
253
+ "files": files,
254
+ "package_version": package_version,
255
+ "limitations": _LIMITATIONS,
256
+ }
257
+
258
+
259
+ def _adapter_lifecycle_check(repo_root: Path, package_version: str | None) -> dict:
260
+ report = _lifecycle_findings(repo_root, package_version)
261
+ findings = report["files"]
262
+ return {
263
+ "name": "adapter_lifecycle",
264
+ "ok": not findings,
265
+ "required": False,
266
+ "repair": (
267
+ "Refresh drifted init artifacts: "
268
+ + "; ".join(dict.fromkeys(f["repair"] for f in findings))
269
+ if findings
270
+ else ""
271
+ ),
272
+ "level": "degraded" if findings else "ok",
273
+ "detail": report,
274
+ }
275
+
276
+
38
277
  def run_checks(
39
278
  *,
40
279
  run_root: str | None = None,
@@ -114,6 +353,10 @@ def run_checks(
114
353
  required=False,
115
354
  ))
116
355
 
356
+ # Unconditional: a nonexistent root classifies as not-initialized rather
357
+ # than omitting the check entirely (uniform skip entry).
358
+ checks.append(_adapter_lifecycle_check(preference_root, version))
359
+
117
360
  # Optional: Playwright for evidence capture.
118
361
  playwright_ok = importlib.util.find_spec("playwright") is not None
119
362
  checks.append(_check(
@@ -649,6 +649,60 @@ def _agents_md_floor_files(version: str, out_dir: Path) -> list[tuple[str, str]]
649
649
  return [_marker_entry(out_dir, "AGENTS.md", version, "".join(block_parts))]
650
650
 
651
651
 
652
+ # ---------------------------------------------------------------------------
653
+ # Zed renderer (Tier 2)
654
+ # ---------------------------------------------------------------------------
655
+
656
+ # Zed reads project-root rules via a first-match priority list with `.rules`
657
+ # on top, then `.cursorrules`, `.windsurfrules`, `.clinerules`,
658
+ # `.github/copilot-instructions.md`, `CLAUDE.md`, `AGENTS.md`, … (zed.dev
659
+ # agent rules docs, fetched 2026-09-19). Creating `.rules` when a
660
+ # lower-priority competitor exists would silently shadow the user's own
661
+ # rules, so the renderer refuses to introduce one into that state.
662
+ _ZED_RULES_COMPETITORS = (
663
+ ".cursorrules",
664
+ ".windsurfrules",
665
+ ".clinerules",
666
+ "CLAUDE.md",
667
+ ".github/copilot-instructions.md",
668
+ )
669
+
670
+
671
+ def _zed_files(version: str, out_dir: Path) -> list[tuple[str, str]]:
672
+ files: list[tuple[str, str]] = []
673
+ skills = _read_skills()
674
+
675
+ # `.rules` — marker-block (may pre-exist). Skip creating a *new* `.rules`
676
+ # while a documented lower-priority competitor is present; an existing
677
+ # `.rules` (ours or the user's) takes refresh/append semantics instead,
678
+ # since Zed already reads that exact file.
679
+ rules_existing = _existing_text(out_dir, ".rules")
680
+ if rules_existing is not None or not any(
681
+ (out_dir / c).exists() for c in _ZED_RULES_COMPETITORS
682
+ ):
683
+ block_parts = _digest_head(skills)
684
+ files.append(_marker_entry(out_dir, ".rules", version, "".join(block_parts)))
685
+
686
+ # .zed/settings.json — merge-safe context_servers (project-level MCP).
687
+ # Official docs (fetched 2026-09-19) document the stdio entry as
688
+ # {"command": "…", "args": […], "env": {…}}; community examples also show
689
+ # an object form ({"command": {"path": …, "args": […]}}) — the string
690
+ # form is pinned here per the official page, and the merge keeps any
691
+ # user-managed entries verbatim.
692
+ mcp_servers = _mcp_servers_abs()
693
+ zed_servers: dict = {}
694
+ for name, srv in mcp_servers.items():
695
+ entry: dict = {"command": srv["command"], "args": srv["args"]}
696
+ if "env" in srv and any(v for v in srv["env"].values()):
697
+ entry["env"] = srv["env"]
698
+ zed_servers[name] = entry
699
+ files.append(
700
+ _merge_json_entry(out_dir, ".zed/settings.json", {"context_servers": zed_servers})
701
+ )
702
+
703
+ return files
704
+
705
+
652
706
  # ---------------------------------------------------------------------------
653
707
  # Renderer dispatch
654
708
  # ---------------------------------------------------------------------------
@@ -664,6 +718,7 @@ _SPECIALIZED_RENDERERS: dict[str, Renderer] = {
664
718
  "opencode": _opencode_files,
665
719
  "windsurf": _windsurf_files,
666
720
  "github-copilot": _github_copilot_files,
721
+ "zed": _zed_files,
667
722
  }
668
723
 
669
724
 
@@ -679,18 +734,19 @@ def _renderer_for(row: AgentRow) -> Renderer | None:
679
734
  return _SPECIALIZED_RENDERERS.get(row.agent, _agents_md_floor_files)
680
735
 
681
736
 
682
- def render(agent: str, out_dir: Path | None = None, *, dry_run: bool = False) -> dict:
683
- """Render adapter artifacts for *agent*. Returns the manifest dict.
737
+ def render_entries(
738
+ agent: str, out_dir: Path | None = None
739
+ ) -> tuple[str, Path, list[tuple[str, str]]]:
740
+ """Read-only seam: ``(version, out_dir, [(rel, content)])`` for *agent*.
684
741
 
685
- When *dry_run* is True, no files are written.
686
- Tier-1 agents default out_dir to PKG; Tier-2/3 default to cwd.
742
+ Resolves the identical renderer path as render() but never touches the
743
+ filesystem — the single read seam behind the doctor adapter-lifecycle
744
+ check (CONTEXT.md "Adapter lifecycle check", 2026-09-19).
687
745
  """
688
746
  row = get_agent(agent)
689
747
  if row is None:
690
748
  raise ValueError(f"unknown agent: {agent!r}")
691
749
 
692
- version = _get_version()
693
-
694
750
  renderer = _renderer_for(row)
695
751
  if renderer is None:
696
752
  raise NotImplementedError(
@@ -701,7 +757,17 @@ def render(agent: str, out_dir: Path | None = None, *, dry_run: bool = False) ->
701
757
  if out_dir is None:
702
758
  out_dir = _PKG_DIR if row.tier == 1 else Path.cwd()
703
759
 
704
- files = renderer(version, out_dir)
760
+ version = _get_version()
761
+ return version, out_dir, renderer(version, out_dir)
762
+
763
+
764
+ def render(agent: str, out_dir: Path | None = None, *, dry_run: bool = False) -> dict:
765
+ """Render adapter artifacts for *agent*. Returns the manifest dict.
766
+
767
+ When *dry_run* is True, no files are written.
768
+ Tier-1 agents default out_dir to PKG; Tier-2/3 default to cwd.
769
+ """
770
+ version, out_dir, files = render_entries(agent, out_dir)
705
771
 
706
772
  manifest = {
707
773
  "agent": agent,