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 +1 -1
- package/package.json +1 -1
- package/scripts/adapter_matrix.py +10 -0
- package/scripts/doctor.py +243 -0
- package/scripts/generate_adapter.py +73 -7
package/codex/AGENTS.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "design-playbook",
|
|
3
|
-
"version": "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
|
|
683
|
-
|
|
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
|
-
|
|
686
|
-
|
|
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
|
-
|
|
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,
|