design-playbook 0.23.0 → 0.24.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.
- package/codex/AGENTS.md +1 -1
- package/package.json +1 -1
- package/scripts/doctor.py +241 -0
- package/scripts/generate_adapter.py +19 -8
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.0",
|
|
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",
|
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,237 @@ 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
|
+
}
|
|
63
|
+
# Whole-file candidates carrying the generated-by marker outside namespaced
|
|
64
|
+
# dirs (marker-block targets are always re-rendered in place, so they are
|
|
65
|
+
# discovered here rather than via orphan scanning).
|
|
66
|
+
_WHOLE_FILE_CANDIDATES = (
|
|
67
|
+
"AGENTS.md",
|
|
68
|
+
"GEMINI.md",
|
|
69
|
+
".github/copilot-instructions.md",
|
|
70
|
+
"design-playbook-mcp-setup.md",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
_LIMITATIONS = (
|
|
74
|
+
"marker-less JSON merge targets (.mcp.json, opencode.json, "
|
|
75
|
+
"settings.json) are never attributed; re-init may still merge into them"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read_text_safe(path: Path) -> str | None:
|
|
80
|
+
try:
|
|
81
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
82
|
+
except OSError:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _normalize_generation(text: str) -> str:
|
|
87
|
+
"""Marker-version and CRLF normalization for byte comparison."""
|
|
88
|
+
return _MARKER_NORM_RE.sub(
|
|
89
|
+
"generated-by design-playbook vNORM", text.replace("\r\n", "\n")
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _iter_orphan_candidates(repo_root: Path):
|
|
94
|
+
"""Yield (agent, path, rel) for every file under a namespaced output
|
|
95
|
+
root — the single walk behind both the discovery gate and the orphan
|
|
96
|
+
scan, so _ORPHAN_SCAN_DIRS has exactly one interpretation."""
|
|
97
|
+
for agent, dirs in _ORPHAN_SCAN_DIRS.items():
|
|
98
|
+
for d in dirs:
|
|
99
|
+
base = repo_root / d
|
|
100
|
+
if not base.is_dir():
|
|
101
|
+
continue
|
|
102
|
+
for f in sorted(base.iterdir()):
|
|
103
|
+
if f.is_file():
|
|
104
|
+
yield agent, f, f.relative_to(repo_root).as_posix()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _lifecycle_findings(repo_root: Path, package_version: str | None) -> dict:
|
|
108
|
+
"""Classify generated init artifacts under *repo_root*. Read-only and
|
|
109
|
+
fail-open per agent: a renderer that cannot compute content (malformed
|
|
110
|
+
JSON merge target, undecodable marker file) degrades to a render_error
|
|
111
|
+
finding instead of crashing the doctor report.
|
|
112
|
+
|
|
113
|
+
Only files carrying the generated-by marker are judged; marker-less
|
|
114
|
+
files (user-authored content, marker-less JSON merge targets) are never
|
|
115
|
+
attributed — an unreadable file counts as unattributed for the same
|
|
116
|
+
reason (it cannot be judged as ours).
|
|
117
|
+
"""
|
|
118
|
+
files: list[dict] = []
|
|
119
|
+
counts = {
|
|
120
|
+
"drifted": 0,
|
|
121
|
+
"orphaned": 0,
|
|
122
|
+
"clean": 0,
|
|
123
|
+
"absent": 0,
|
|
124
|
+
"unattributed": 0,
|
|
125
|
+
"render_error": 0,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
def record(agent: str, rel: str, cls: str, marker_version: str | None = None) -> None:
|
|
129
|
+
counts[cls] += 1
|
|
130
|
+
if cls in ("drifted", "orphaned"):
|
|
131
|
+
files.append({
|
|
132
|
+
"agent": agent,
|
|
133
|
+
"path": rel,
|
|
134
|
+
"cls": cls,
|
|
135
|
+
"marker_version": marker_version,
|
|
136
|
+
"repair": (
|
|
137
|
+
f"npx design-playbook init {agent}"
|
|
138
|
+
if cls == "drifted"
|
|
139
|
+
else f"delete {rel} then npx design-playbook init {agent}"
|
|
140
|
+
),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
# Discovery gate: without any generated-by marker anywhere there is
|
|
144
|
+
# nothing to attribute — skip the per-agent renders entirely. One walk
|
|
145
|
+
# over the namespaced roots feeds both this gate and the orphan scan.
|
|
146
|
+
orphan_candidates = list(_iter_orphan_candidates(repo_root))
|
|
147
|
+
discovered = False
|
|
148
|
+
for rel in _WHOLE_FILE_CANDIDATES:
|
|
149
|
+
text = _read_text_safe(repo_root / rel)
|
|
150
|
+
if text is not None and _MARKER_RE.search(text):
|
|
151
|
+
discovered = True
|
|
152
|
+
break
|
|
153
|
+
if not discovered:
|
|
154
|
+
for _agent, path, _rel in orphan_candidates:
|
|
155
|
+
text = _read_text_safe(path)
|
|
156
|
+
if text is not None and _MARKER_RE.search(text):
|
|
157
|
+
discovered = True
|
|
158
|
+
break
|
|
159
|
+
if not discovered:
|
|
160
|
+
return {
|
|
161
|
+
"status": "not-initialized",
|
|
162
|
+
"counts": counts,
|
|
163
|
+
"files": [],
|
|
164
|
+
"package_version": package_version,
|
|
165
|
+
"limitations": _LIMITATIONS,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# One render pass over all non-native agents, grouped by target path.
|
|
169
|
+
# AGENTS.md is a shared target (opencode + every tier-3 floor agent), so
|
|
170
|
+
# a file is clean when it matches ANY current candidate render; drift is
|
|
171
|
+
# "matches no current renderer", never "differs from one agent's render".
|
|
172
|
+
renders: dict[str, list[tuple[str, str]]] = {}
|
|
173
|
+
rendered_by_agent: dict[str, set[str]] = {}
|
|
174
|
+
render_failed: set[str] = set()
|
|
175
|
+
for row in MATRIX:
|
|
176
|
+
if row.native:
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
_version, _out_dir, entries = render_entries(row.agent, repo_root)
|
|
180
|
+
except (ValueError, OSError) as exc:
|
|
181
|
+
# Fail-loud renderer inputs (malformed merge JSON, undecodable
|
|
182
|
+
# marker file) degrade to a finding; the other agents still
|
|
183
|
+
# classify and the doctor keeps reporting. No rendered set means
|
|
184
|
+
# the orphan scan must skip this agent too — flagging its
|
|
185
|
+
# marker'd files orphaned would be a false positive.
|
|
186
|
+
counts["render_error"] += 1
|
|
187
|
+
render_failed.add(row.agent)
|
|
188
|
+
files.append({
|
|
189
|
+
"agent": row.agent,
|
|
190
|
+
"path": None,
|
|
191
|
+
"cls": "render_error",
|
|
192
|
+
"marker_version": None,
|
|
193
|
+
"reason": str(exc),
|
|
194
|
+
"repair": (
|
|
195
|
+
f"fix or remove the unreadable config, then "
|
|
196
|
+
f"npx design-playbook init {row.agent}"
|
|
197
|
+
),
|
|
198
|
+
})
|
|
199
|
+
continue
|
|
200
|
+
rendered_by_agent[row.agent] = {rel for rel, _content in entries}
|
|
201
|
+
for rel, content in entries:
|
|
202
|
+
renders.setdefault(rel, []).append((row.agent, content))
|
|
203
|
+
|
|
204
|
+
for rel in sorted(renders):
|
|
205
|
+
candidates = renders[rel]
|
|
206
|
+
path = repo_root / rel
|
|
207
|
+
if not path.is_file():
|
|
208
|
+
counts["absent"] += 1
|
|
209
|
+
continue
|
|
210
|
+
actual = _read_text_safe(path)
|
|
211
|
+
m = _MARKER_RE.search(actual) if actual is not None else None
|
|
212
|
+
if m is None:
|
|
213
|
+
counts["unattributed"] += 1
|
|
214
|
+
continue
|
|
215
|
+
if any(
|
|
216
|
+
_normalize_generation(content) == _normalize_generation(actual)
|
|
217
|
+
for _agent, content in candidates
|
|
218
|
+
):
|
|
219
|
+
counts["clean"] += 1
|
|
220
|
+
continue
|
|
221
|
+
agents = [agent for agent, _content in candidates]
|
|
222
|
+
if len(agents) == 1:
|
|
223
|
+
agent: str | None = agents[0]
|
|
224
|
+
repair = f"npx design-playbook init {agent}"
|
|
225
|
+
else:
|
|
226
|
+
agent = None
|
|
227
|
+
repair = (
|
|
228
|
+
f"npx design-playbook init <your-agent> "
|
|
229
|
+
f"({rel} is shared by: {', '.join(agents)})"
|
|
230
|
+
)
|
|
231
|
+
counts["drifted"] += 1
|
|
232
|
+
files.append({
|
|
233
|
+
"agent": agent,
|
|
234
|
+
"path": rel,
|
|
235
|
+
"cls": "drifted",
|
|
236
|
+
"marker_version": m.group(1),
|
|
237
|
+
"repair": repair,
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
for agent, path, rel in orphan_candidates:
|
|
241
|
+
if agent in render_failed or rel in rendered_by_agent.get(agent, set()):
|
|
242
|
+
continue
|
|
243
|
+
text = _read_text_safe(path)
|
|
244
|
+
m = _MARKER_RE.search(text) if text is not None else None
|
|
245
|
+
if m is not None:
|
|
246
|
+
record(agent, rel, "orphaned", m.group(1))
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
"status": "scanned",
|
|
250
|
+
"counts": counts,
|
|
251
|
+
"files": files,
|
|
252
|
+
"package_version": package_version,
|
|
253
|
+
"limitations": _LIMITATIONS,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _adapter_lifecycle_check(repo_root: Path, package_version: str | None) -> dict:
|
|
258
|
+
report = _lifecycle_findings(repo_root, package_version)
|
|
259
|
+
findings = report["files"]
|
|
260
|
+
return {
|
|
261
|
+
"name": "adapter_lifecycle",
|
|
262
|
+
"ok": not findings,
|
|
263
|
+
"required": False,
|
|
264
|
+
"repair": (
|
|
265
|
+
"Refresh drifted init artifacts: "
|
|
266
|
+
+ "; ".join(dict.fromkeys(f["repair"] for f in findings))
|
|
267
|
+
if findings
|
|
268
|
+
else ""
|
|
269
|
+
),
|
|
270
|
+
"level": "degraded" if findings else "ok",
|
|
271
|
+
"detail": report,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
38
275
|
def run_checks(
|
|
39
276
|
*,
|
|
40
277
|
run_root: str | None = None,
|
|
@@ -114,6 +351,10 @@ def run_checks(
|
|
|
114
351
|
required=False,
|
|
115
352
|
))
|
|
116
353
|
|
|
354
|
+
# Unconditional: a nonexistent root classifies as not-initialized rather
|
|
355
|
+
# than omitting the check entirely (uniform skip entry).
|
|
356
|
+
checks.append(_adapter_lifecycle_check(preference_root, version))
|
|
357
|
+
|
|
117
358
|
# Optional: Playwright for evidence capture.
|
|
118
359
|
playwright_ok = importlib.util.find_spec("playwright") is not None
|
|
119
360
|
checks.append(_check(
|
|
@@ -679,18 +679,19 @@ def _renderer_for(row: AgentRow) -> Renderer | None:
|
|
|
679
679
|
return _SPECIALIZED_RENDERERS.get(row.agent, _agents_md_floor_files)
|
|
680
680
|
|
|
681
681
|
|
|
682
|
-
def
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
682
|
+
def render_entries(
|
|
683
|
+
agent: str, out_dir: Path | None = None
|
|
684
|
+
) -> tuple[str, Path, list[tuple[str, str]]]:
|
|
685
|
+
"""Read-only seam: ``(version, out_dir, [(rel, content)])`` for *agent*.
|
|
686
|
+
|
|
687
|
+
Resolves the identical renderer path as render() but never touches the
|
|
688
|
+
filesystem — the single read seam behind the doctor adapter-lifecycle
|
|
689
|
+
check (CONTEXT.md "Adapter lifecycle check", 2026-09-19).
|
|
687
690
|
"""
|
|
688
691
|
row = get_agent(agent)
|
|
689
692
|
if row is None:
|
|
690
693
|
raise ValueError(f"unknown agent: {agent!r}")
|
|
691
694
|
|
|
692
|
-
version = _get_version()
|
|
693
|
-
|
|
694
695
|
renderer = _renderer_for(row)
|
|
695
696
|
if renderer is None:
|
|
696
697
|
raise NotImplementedError(
|
|
@@ -701,7 +702,17 @@ def render(agent: str, out_dir: Path | None = None, *, dry_run: bool = False) ->
|
|
|
701
702
|
if out_dir is None:
|
|
702
703
|
out_dir = _PKG_DIR if row.tier == 1 else Path.cwd()
|
|
703
704
|
|
|
704
|
-
|
|
705
|
+
version = _get_version()
|
|
706
|
+
return version, out_dir, renderer(version, out_dir)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def render(agent: str, out_dir: Path | None = None, *, dry_run: bool = False) -> dict:
|
|
710
|
+
"""Render adapter artifacts for *agent*. Returns the manifest dict.
|
|
711
|
+
|
|
712
|
+
When *dry_run* is True, no files are written.
|
|
713
|
+
Tier-1 agents default out_dir to PKG; Tier-2/3 default to cwd.
|
|
714
|
+
"""
|
|
715
|
+
version, out_dir, files = render_entries(agent, out_dir)
|
|
705
716
|
|
|
706
717
|
manifest = {
|
|
707
718
|
"agent": agent,
|