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 CHANGED
@@ -1,4 +1,4 @@
1
- <!-- generated-by design-playbook v0.23.0 -->
1
+ <!-- generated-by design-playbook v0.24.0 -->
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.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 render(agent: str, out_dir: Path | None = None, *, dry_run: bool = False) -> dict:
683
- """Render adapter artifacts for *agent*. Returns the manifest dict.
684
-
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.
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
- files = renderer(version, out_dir)
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,