design-playbook 0.22.0 → 0.22.2

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.
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
  from copy import deepcopy
12
12
  import hashlib
13
13
  import json
14
+ import re
14
15
  import sys
15
16
  import unittest
16
17
  from pathlib import Path
@@ -26,6 +27,8 @@ from design_playbook.mcp.run_console.contract import ( # noqa: E402
26
27
  validate_snapshot,
27
28
  )
28
29
  from design_playbook.mcp.run_console.repair_packet import ( # noqa: E402
30
+ MSG_ABSENT_ASSERTION,
31
+ MSG_DISPOSITION_UNKNOWN,
29
32
  MSG_NO_BLOCKING,
30
33
  MSG_NO_COMMAND,
31
34
  MSG_NO_INVALIDATED,
@@ -46,6 +49,33 @@ _HTML = (_DIR / "app.html").read_text(encoding="utf-8")
46
49
  _CSS = (_DIR / "app.css").read_text(encoding="utf-8")
47
50
  _COMMAND = "qoder run --resume run_example --next ui-evaluator"
48
51
 
52
+ # Cross-implementation locks: app.js carries a second copy of the packet
53
+ # derivation and copy renderer, including the seven shared gap messages.
54
+ # The zh-CN strings pin the localized reason copy; the tuple pins every
55
+ # shared message constant so a one-sided edit fails a gate.
56
+ _ZH_NO_INVALIDATED = "快照未投影失效证据集。"
57
+ _ZH_NO_RECAPTURE = "快照未投影重采要求。"
58
+ _ZH_NO_RESUME_STAGE = "快照未投影明确的恢复阶段;最新观测阶段并非恢复目标。"
59
+ _ZH_NO_COMMAND = "本次快照中该动作未携带可复制的智能体指令。"
60
+ _ZH_NO_BLOCKING = "本次快照未投影任何阻塞性发现。"
61
+ _ZH_DISPOSITION_UNKNOWN = "存在发现,但其是否阻塞并非责任方已知。"
62
+ _ZH_ASSERTION_ABSENT = "此断言在快照中缺失。"
63
+ _ZH_COPY_HEADING = "修复包(派生视图;仅复制;绝不执行)"
64
+
65
+ _JS_MSG_TO_PY = (
66
+ ("PACKET_MSG_ABSENT", MSG_ABSENT_ASSERTION),
67
+ ("PACKET_MSG_NO_BLOCKING", MSG_NO_BLOCKING),
68
+ ("PACKET_MSG_DISPOSITION_UNKNOWN", MSG_DISPOSITION_UNKNOWN),
69
+ ("PACKET_MSG_NO_INVALIDATED", MSG_NO_INVALIDATED),
70
+ ("PACKET_MSG_NO_RECAPTURE", MSG_NO_RECAPTURE),
71
+ ("PACKET_MSG_NO_RESUME_STAGE", MSG_NO_RESUME_STAGE),
72
+ ("PACKET_MSG_NO_COMMAND", MSG_NO_COMMAND),
73
+ )
74
+
75
+ _COPY_LINE_SHAPE = re.compile(
76
+ r"^([^(]+) \((known|unknown|stale|inconsistent)\): (.*)$"
77
+ )
78
+
49
79
  _PLAYWRIGHT = None
50
80
  _BROWSER = None
51
81
 
@@ -117,6 +147,74 @@ def _blocking_snapshot(*, command: str | None = None) -> dict[str, object]:
117
147
  label="Verdict is Recirculate — repair from point-back findings.",
118
148
  owner={"actor": "agent", "role": None},
119
149
  copyableAgentCommand=command,
150
+ invalidatedEvidence=["L6.3"],
151
+ resumeStage="ui-evaluator",
152
+ recaptureRequirement=(
153
+ "Recapture only invalidated evidence, then re-run ui-evaluator."
154
+ ),
155
+ )
156
+ return document
157
+
158
+
159
+ def _js_packet_message_constants() -> dict[str, str]:
160
+ """Extract the PACKET_MSG_* constants from the shipped app.js source."""
161
+ pattern = re.compile(r'var (PACKET_MSG_\w+) =((?:\s*"[^"]*"(?:\s*\+)*)+\s*);')
162
+ return {
163
+ name: "".join(re.findall(r'"([^"]*)"', expression))
164
+ for name, expression in pattern.findall(_JS)
165
+ }
166
+
167
+
168
+ def _stale_inconsistent_snapshot() -> dict[str, object]:
169
+ """Degraded snapshot: a stale intent plus an inconsistent verdict."""
170
+ document = _valid()
171
+ document["identity"]["snapshot"]["buildState"] = "degraded" # type: ignore[index]
172
+ document["sources"]["items"][0].update( # type: ignore[index]
173
+ verifiedHash=contract_fixtures._HASH_2, freshness="changed"
174
+ )
175
+ _bind_source_set_hash(document)
176
+ summary = document["intent"]["summary"] # type: ignore[index]
177
+ summary.update(
178
+ availability="stale",
179
+ reason=_reason(
180
+ "source-changed-during-build",
181
+ ["source.common"],
182
+ observed_hashes=[contract_fixtures._HASH_1],
183
+ verified_hashes=[contract_fixtures._HASH_2],
184
+ ),
185
+ )
186
+ summary["source"].update(verifiedSetHash=contract_fixtures._HASH_2) # type: ignore[union-attr]
187
+ verdict = document["evaluation"]["verdict"] # type: ignore[index]
188
+ verdict.update(
189
+ availability="inconsistent",
190
+ result=None,
191
+ reason=_reason(
192
+ "conflicting-authorities",
193
+ ["source.common", "source.evaluator-report"],
194
+ observed_hashes=[contract_fixtures._HASH_1, contract_fixtures._HASH_3],
195
+ verified_hashes=[contract_fixtures._HASH_1, contract_fixtures._HASH_3],
196
+ conflicts=[
197
+ {
198
+ "sourceRef": "source.evaluator-report",
199
+ "hash": contract_fixtures._HASH_3,
200
+ "summary": "verdict contradicts the findings ledger",
201
+ }
202
+ ],
203
+ ),
204
+ )
205
+ verdict["source"]["refs"] = ["source.common", "source.evaluator-report"] # type: ignore[union-attr]
206
+ return document
207
+
208
+
209
+ def _unreadable_finding_snapshot() -> dict[str, object]:
210
+ """Degraded snapshot: the only finding has an owner-unknown disposition."""
211
+ document = _valid()
212
+ document["identity"]["snapshot"]["buildState"] = "degraded" # type: ignore[index]
213
+ finding = document["evaluation"]["findings"][0] # type: ignore[index]
214
+ finding.update(
215
+ availability="unknown",
216
+ result=None,
217
+ reason=_reason("owner-unmapped", ["source.common"]),
120
218
  )
121
219
  return document
122
220
 
@@ -165,8 +263,13 @@ class RepairPacketProjectionTest(unittest.TestCase):
165
263
  self.assertEqual(packet["nextCommand"]["reason"]["code"], NOT_PRODUCED)
166
264
  label = "Verdict is Recirculate — repair from point-back findings."
167
265
  self.assertNotEqual(packet["nextCommand"]["value"], label)
168
- self.assertEqual(packet["invalidatedEvidence"]["availability"], "unknown")
169
- self.assertEqual(packet["recaptureRequirement"]["availability"], "unknown")
266
+ self.assertEqual(packet["invalidatedEvidence"]["availability"], "known")
267
+ self.assertEqual(packet["invalidatedEvidence"]["value"], ["L6.3"])
268
+ self.assertEqual(packet["recaptureRequirement"]["availability"], "known")
269
+ self.assertEqual(
270
+ packet["recaptureRequirement"]["value"],
271
+ "Recapture only invalidated evidence, then re-run ui-evaluator.",
272
+ )
170
273
 
171
274
  def test_owner_supplied_command_is_copied_verbatim(self) -> None:
172
275
  packet = derive_repair_packet(_blocking_snapshot(command=_COMMAND))
@@ -174,18 +277,17 @@ class RepairPacketProjectionTest(unittest.TestCase):
174
277
  self.assertEqual(packet["nextCommand"]["value"], _COMMAND)
175
278
  self.assertIn(_COMMAND, packet["copyText"])
176
279
 
177
- def test_resume_stage_is_a_gap_not_inferred_from_progress_or_command(self) -> None:
280
+ def test_resume_stage_is_owner_explicit_and_not_inferred_from_progress(self) -> None:
178
281
  """Spec rule 19: latest observed stage is not a resume target."""
179
282
  document = _blocking_snapshot(command=_COMMAND)
180
283
  progress = document["execution"]["progress"] # type: ignore[index]
181
284
  progress["result"]["latestObservedStage"] = "fill" # type: ignore[index]
182
285
  packet = derive_repair_packet(document)
183
- self.assertEqual(packet["resumeStage"]["availability"], "unknown")
184
- self.assertIsNone(packet["resumeStage"]["value"])
185
- self.assertEqual(packet["resumeStage"]["reason"]["code"], NOT_PRODUCED)
186
- self.assertEqual(packet["resumeStage"]["reason"]["message"], MSG_NO_RESUME_STAGE)
286
+ self.assertEqual(packet["resumeStage"]["availability"], "known")
287
+ self.assertEqual(packet["resumeStage"]["value"], "ui-evaluator")
288
+ self.assertIsNone(packet["resumeStage"]["reason"])
187
289
  rendered = json.dumps(packet["resumeStage"])
188
- self.assertNotIn("fill", rendered)
290
+ self.assertNotIn('"fill"', rendered)
189
291
  self.assertNotIn("--resume", rendered)
190
292
 
191
293
  def test_stale_intent_is_stale_context_not_current(self) -> None:
@@ -305,10 +407,16 @@ class RepairPacketRenderContractTest(unittest.TestCase):
305
407
  self.assertNotIn("/api/v1/actions/rerun", _JS)
306
408
  self.assertNotIn("executeRepair", _JS)
307
409
 
308
- def test_resume_stage_is_a_static_gap_not_derived_from_progress(self) -> None:
309
- self.assertIn("packetGap(PACKET_MSG_NO_RESUME_STAGE)", _JS)
410
+ def test_resume_stage_comes_from_owner_next_action_not_progress(self) -> None:
411
+ # Owner-produced resumeStage rides nextActions.primary; the JS
412
+ # helper keeps the gap message when the owner did not produce it.
413
+ # Never invent a packetResumeStage parser over progress.
414
+ self.assertIn("packetNextActionField(", _JS)
415
+ self.assertIn('"resumeStage"', _JS)
416
+ self.assertIn("PACKET_MSG_NO_RESUME_STAGE", _JS)
310
417
  self.assertIn("packet_not_produced_resume_stage", _JS)
311
418
  self.assertNotIn("packetResumeStage", _JS)
419
+ self.assertNotIn("execution.progress", _JS.split("function deriveRepairPacket")[1].split("function ")[0])
312
420
 
313
421
  def test_ui_route_table_is_unchanged(self) -> None:
314
422
  resources = UIResources()
@@ -375,8 +483,12 @@ class RepairPacketBrowserTest(browser_harness.BrowserTestCase):
375
483
  self.assertIn("add a consequence-confirmation dialog", packet)
376
484
  self.assertIn("declaration", self._field("declarationOwner").inner_text())
377
485
  self.assertIn("agent", self._field("nextOwner").inner_text())
378
- self.assertIn("not project an invalidated-evidence set", packet)
379
- self.assertIn("not project a recapture requirement", packet)
486
+ self.assertIn("L6.3", self._field("invalidatedEvidence").inner_text())
487
+ self.assertIn("ui-evaluator", self._field("resumeStage").inner_text())
488
+ self.assertIn(
489
+ "Recapture only invalidated evidence",
490
+ self._field("recaptureRequirement").inner_text(),
491
+ )
380
492
  self.assertNotIn("Pass", self._field("verdict").inner_text())
381
493
 
382
494
  def test_blocked_run_copy_advances_the_journey_with_owner_command(self) -> None:
@@ -548,3 +660,181 @@ class RepairPacketBrowserTest(browser_harness.BrowserTestCase):
548
660
  expect(self._packet()).to_be_visible()
549
661
  width = self.page.evaluate("() => document.scrollingElement.scrollWidth")
550
662
  self.assertLessEqual(width, 320 + 2)
663
+
664
+
665
+ class RepairPacketCrossImplementationTest(browser_harness.BrowserTestCase):
666
+ """JS and Python must derive and render the same packet.
667
+
668
+ app.js re-implements the projection and copy renderer from
669
+ repair_packet.py, including the seven shared gap messages. A silent
670
+ drift would flip the localized UI back to English reasons with no
671
+ failing gate, so every assertion here is a fail-direction lock: the
672
+ same snapshot is rendered through the real browser and its copied
673
+ summary is compared against the Python projection. Interface labels
674
+ may differ per locale, so the comparison splits each copy line into
675
+ label, availability, and reason parts instead of raw equality.
676
+ """
677
+
678
+ def _open_via_route(self, snapshot: dict[str, object]) -> None:
679
+ def fulfill(route):
680
+ route.fulfill(
681
+ status=200,
682
+ content_type="application/json",
683
+ body=json.dumps(snapshot),
684
+ )
685
+
686
+ # A repeated goto to the identical URL is a same-document no-op, so
687
+ # leave the document first: every variant must render from a clean
688
+ # page (fresh language state, single active route handler).
689
+ self.page.goto("about:blank")
690
+ self.page.unroute("**/api/v1/snapshot")
691
+ self.page.route("**/api/v1/snapshot", fulfill)
692
+ self.page.goto(self.console.url(f"#token={self.console.token}"))
693
+ expect(self.page.locator("#view-ready")).to_be_visible()
694
+
695
+ def _copy_summary(self, *, status_text: str = "plain text") -> str:
696
+ self.context.grant_permissions(["clipboard-read", "clipboard-write"])
697
+ self.page.locator("#packet-copy-summary").click()
698
+ expect(self.page.locator("#packet-copy-summary-status")).to_contain_text(
699
+ status_text
700
+ )
701
+ text = self.page.evaluate("() => navigator.clipboard.readText()")
702
+ # Chromium normalizes written LF to CRLF on the Windows clipboard.
703
+ return text.replace("\r\n", "\n").replace("\r", "\n")
704
+
705
+ def _assert_copy_summary_matches(self, packet, summary: str) -> None:
706
+ py_lines = packet["copyText"].split("\n")
707
+ js_lines = summary.split("\n")
708
+ self.assertEqual(len(js_lines), len(py_lines))
709
+ self.assertEqual(js_lines[0], py_lines[0])
710
+ for index, key in enumerate(PACKET_KEYS, start=1):
711
+ py_match = _COPY_LINE_SHAPE.match(py_lines[index])
712
+ js_match = _COPY_LINE_SHAPE.match(js_lines[index])
713
+ self.assertIsNotNone(py_match, py_lines[index])
714
+ self.assertIsNotNone(js_match, js_lines[index])
715
+ self.assertEqual(js_match.group(1), py_match.group(1), f"{key} label")
716
+ self.assertEqual(
717
+ js_match.group(2), py_match.group(2), f"{key} availability"
718
+ )
719
+ reason = packet[key]["reason"]
720
+ if not (
721
+ isinstance(reason, dict) and (reason.get("code") or reason.get("message"))
722
+ ):
723
+ # Known facts carry no reason, so the whole line must agree.
724
+ self.assertEqual(js_lines[index], py_lines[index], f"{key} line")
725
+ continue
726
+ code = reason.get("code") or ""
727
+ message = reason.get("message") or ""
728
+ separator = ": " if code and message else ""
729
+ expected_tail = f" ({code}{separator}{message})"
730
+ self.assertTrue(
731
+ js_match.group(3).endswith(expected_tail),
732
+ f"{key}: JS reason drifted from the Python projection: "
733
+ f"{js_lines[index]!r} does not end with {expected_tail!r}",
734
+ )
735
+
736
+ def _zh_reason(self, key: str) -> str:
737
+ return self.page.locator(
738
+ f'#repair-packet-grid [data-packet-field="{key}"] .reason-block'
739
+ ).inner_text()
740
+
741
+ def _switch_to_zh(self) -> None:
742
+ self.page.locator("#lang-toggle-button").click()
743
+ expect(self.page.locator("html")).to_have_attribute("lang", "zh-CN")
744
+
745
+ def _disposition_unknown_snapshot(self) -> dict[str, object]:
746
+ # Unknown assertions in valid snapshots always carry a reason, so
747
+ # the fallback message is only reachable through the browser copy.
748
+ snapshot = self.console.snapshot()
749
+ snapshot["evaluation"]["findings"] = [deepcopy(
750
+ contract_fixtures._valid_snapshot()["evaluation"]["findings"][0]
751
+ )]
752
+ finding = snapshot["evaluation"]["findings"][0]
753
+ finding["availability"] = "unknown"
754
+ finding["result"] = None
755
+ finding["reason"] = None
756
+ return snapshot
757
+
758
+ def _absent_summary_snapshot(self) -> dict[str, object]:
759
+ snapshot = self.console.snapshot()
760
+ snapshot["intent"]["summary"] = None
761
+ return snapshot
762
+
763
+ def test_js_and_python_gap_message_constants_are_identical(self) -> None:
764
+ js_constants = _js_packet_message_constants()
765
+ for js_name, py_message in _JS_MSG_TO_PY:
766
+ with self.subTest(constant=js_name):
767
+ self.assertIn(js_name, js_constants)
768
+ self.assertEqual(js_constants[js_name], py_message)
769
+
770
+ def test_real_server_copy_summary_matches_python_projection(self) -> None:
771
+ # Completed (Pass) and blocked (Recirculate with an owner command)
772
+ # snapshots both come from the real server, covering the known
773
+ # value lines and the no-blocking/no-command gap messages.
774
+ for point_back in ("point-back-pass-closed.md", "point-back-recirculate.md"):
775
+ with self.subTest(point_back=point_back):
776
+ self.console.close()
777
+ self.console = browser_harness.ConsoleHarness(point_back=point_back)
778
+ self.addCleanup(self.console.close)
779
+ self.open()
780
+ summary = self._copy_summary()
781
+ packet = derive_repair_packet(self.console.snapshot())
782
+ self._assert_copy_summary_matches(packet, summary)
783
+
784
+ def test_fulfilled_variants_copy_summary_match_python_projection(self) -> None:
785
+ # Degraded snapshots a real server never produces are injected by
786
+ # same-origin route interception, matching the harness convention.
787
+ for name, snapshot in (
788
+ ("stale-intent-and-inconsistent-verdict", _stale_inconsistent_snapshot()),
789
+ ("unreadable-finding-disposition", _unreadable_finding_snapshot()),
790
+ ):
791
+ with self.subTest(variant=name):
792
+ packet = derive_repair_packet(snapshot)
793
+ self._open_via_route(snapshot)
794
+ summary = self._copy_summary()
795
+ self._assert_copy_summary_matches(packet, summary)
796
+
797
+ def test_zh_ui_renders_localized_reasons_not_english_fallback(self) -> None:
798
+ self.open()
799
+ self._switch_to_zh()
800
+ for key, zh_message, en_message in (
801
+ ("invalidatedEvidence", _ZH_NO_INVALIDATED, MSG_NO_INVALIDATED),
802
+ ("recaptureRequirement", _ZH_NO_RECAPTURE, MSG_NO_RECAPTURE),
803
+ ("resumeStage", _ZH_NO_RESUME_STAGE, MSG_NO_RESUME_STAGE),
804
+ ("nextCommand", _ZH_NO_COMMAND, MSG_NO_COMMAND),
805
+ ("finding", _ZH_NO_BLOCKING, MSG_NO_BLOCKING),
806
+ ):
807
+ with self.subTest(field=key):
808
+ text = self._zh_reason(key)
809
+ self.assertIn(zh_message, text)
810
+ self.assertNotIn(en_message, text)
811
+ summary = self._copy_summary(status_text="纯文本")
812
+ self.assertIn(_ZH_COPY_HEADING, summary)
813
+ for zh_message, en_message in (
814
+ (_ZH_NO_INVALIDATED, MSG_NO_INVALIDATED),
815
+ (_ZH_NO_RECAPTURE, MSG_NO_RECAPTURE),
816
+ (_ZH_NO_RESUME_STAGE, MSG_NO_RESUME_STAGE),
817
+ (_ZH_NO_COMMAND, MSG_NO_COMMAND),
818
+ (_ZH_NO_BLOCKING, MSG_NO_BLOCKING),
819
+ ):
820
+ with self.subTest(copy=zh_message):
821
+ self.assertIn(zh_message, summary)
822
+ self.assertNotIn(en_message, summary)
823
+
824
+ def test_zh_ui_localizes_reasons_only_reachable_in_the_browser(self) -> None:
825
+ # The Python contract always demands a reason on unknown assertions,
826
+ # so these two fallback messages exist only on the browser side and
827
+ # cannot be cross-checked through a valid snapshot.
828
+ scenarios = (
829
+ ("disposition-unknown", self._disposition_unknown_snapshot(),
830
+ "finding", _ZH_DISPOSITION_UNKNOWN, MSG_DISPOSITION_UNKNOWN),
831
+ ("absent-summary", self._absent_summary_snapshot(),
832
+ "intent", _ZH_ASSERTION_ABSENT, MSG_ABSENT_ASSERTION),
833
+ )
834
+ for name, snapshot, key, zh_message, en_message in scenarios:
835
+ with self.subTest(scenario=name):
836
+ self._open_via_route(snapshot)
837
+ self._switch_to_zh()
838
+ text = self._zh_reason(key)
839
+ self.assertIn(zh_message, text)
840
+ self.assertNotIn(en_message, text)
@@ -35,6 +35,9 @@ from design_playbook.mcp.preview.integrity import ( # noqa: E402
35
35
  from design_playbook.mcp.run_console.contract import ( # noqa: E402
36
36
  validate_snapshot,
37
37
  )
38
+ from design_playbook.mcp.run_console.repair_packet import ( # noqa: E402
39
+ derive_repair_packet,
40
+ )
38
41
  from design_playbook.mcp.run_console.snapshot_builder import ( # noqa: E402
39
42
  BuiltSnapshot,
40
43
  SnapshotBuildError,
@@ -547,6 +550,67 @@ class DegradingBuildTest(_BuilderTestCase):
547
550
  verdict = _assertion(document, "evaluation.verdict")
548
551
  self.assertEqual(verdict["availability"], "known")
549
552
  self.assertEqual(verdict["result"], "Recirculate")
553
+ result = document["nextActions"]["primary"]["result"]
554
+ self.assertEqual(result["invalidatedEvidence"], ["L6.3"])
555
+ self.assertEqual(result["resumeStage"], "ui-evaluator")
556
+ self.assertEqual(
557
+ result["recaptureRequirement"],
558
+ "Recapture only invalidated evidence, then re-run ui-evaluator.",
559
+ )
560
+
561
+ def test_recirculate_missing_invalidation_stays_unknown_in_packet(self) -> None:
562
+ (self.run_root / "point-back.md").write_text(
563
+ _RECIRCULATE_POINTBACK.split("invalidated:", 1)[0], encoding="utf-8"
564
+ )
565
+
566
+ document = self._rebuild()
567
+ packet = derive_repair_packet(document)
568
+
569
+ self.assertIsNone(
570
+ document["nextActions"]["primary"]["result"]["invalidatedEvidence"]
571
+ )
572
+ self.assertEqual(packet["invalidatedEvidence"]["availability"], "unknown")
573
+ self.assertIsNone(packet["invalidatedEvidence"]["value"])
574
+ self.assertEqual(
575
+ packet["invalidatedEvidence"]["reason"]["code"], "not-produced"
576
+ )
577
+ self.assertEqual(packet["resumeStage"]["value"], "ui-evaluator")
578
+
579
+ def test_recirculate_malformed_invalidation_preserves_packet_gap(self) -> None:
580
+ malformed_entries = (
581
+ " - criterion: L6.4 extra-token\n",
582
+ " - criterion:\n",
583
+ " - criterion L6.4\n",
584
+ " - unknown: L6.4\n",
585
+ " -\n",
586
+ " - criterion: ../private\n",
587
+ " criterion: L6.4\n",
588
+ "invalidated:\n",
589
+ )
590
+ for malformed_entry in malformed_entries:
591
+ with self.subTest(entry=malformed_entry):
592
+ (self.run_root / "point-back.md").write_text(
593
+ _RECIRCULATE_POINTBACK.split("invalidated:", 1)[0]
594
+ + "invalidated:\n - criterion: L6.3\n"
595
+ + malformed_entry
596
+ + " - criterion: L6.5\n",
597
+ encoding="utf-8",
598
+ )
599
+
600
+ document = self._rebuild()
601
+ packet = derive_repair_packet(document)
602
+
603
+ self.assertIsNone(
604
+ document["nextActions"]["primary"]["result"]["invalidatedEvidence"]
605
+ )
606
+ self.assertEqual(
607
+ packet["invalidatedEvidence"]["availability"], "unknown"
608
+ )
609
+ self.assertIsNone(packet["invalidatedEvidence"]["value"])
610
+ self.assertEqual(
611
+ packet["invalidatedEvidence"]["reason"]["code"], "not-produced"
612
+ )
613
+ self.assertEqual(packet["resumeStage"]["value"], "ui-evaluator")
550
614
 
551
615
  def test_truncated_contract_bind_is_partial_write(self) -> None:
552
616
  (self.run_root / "contract-bind.json").write_text(
package/mcp/ui_locale.py CHANGED
@@ -8,7 +8,14 @@ EN = "en"
8
8
 
9
9
 
10
10
  def resolve_ui_locale(locale: str | None = None) -> str:
11
- """Explicit locale wins; keep the adapter's documented CJK-first env policy."""
11
+ """Explicit locale wins; keep the adapter's documented CJK-first env policy.
12
+
13
+ A blank explicit locale means "unspecified" (forms and MCP callers
14
+ encode absence as an empty string) and falls back like ``None``;
15
+ anything else still fails closed with ``ValueError``.
16
+ """
17
+ if locale is not None and not locale.strip():
18
+ locale = None
12
19
  raw = locale if locale is not None else (
13
20
  os.environ.get("DPB_PREVIEW_LANG") or os.environ.get("LANG") or ZH
14
21
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "design-playbook",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
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",
@@ -26,6 +26,7 @@ PublicClaim = Literal[
26
26
  "not-shipped",
27
27
  ]
28
28
  FallbackKind = Literal["safe-path", "evidence-gap"]
29
+ GateState = Literal["not-satisfied", "satisfied", "unknown"]
29
30
 
30
31
  _IMPLEMENTATION_VALUES = frozenset(("absent", "present", "unknown"))
31
32
  _VALIDATION_VALUES = frozenset(
@@ -35,6 +36,7 @@ _AVAILABILITY_VALUES = frozenset(("local", "distributed", "unsupported", "unknow
35
36
  _PUBLIC_CLAIM_VALUES = frozenset(
36
37
  ("stable", "experimental", "blocked-by-gate", "not-shipped")
37
38
  )
39
+ _GATE_STATE_VALUES = frozenset(("not-satisfied", "satisfied", "unknown"))
38
40
 
39
41
 
40
42
  class CapabilityReceiptError(ValueError):
@@ -56,10 +58,14 @@ class CapabilitySourceFacts:
56
58
  availability: AvailabilityState | None = None
57
59
  entrypoint: str | None = None
58
60
  prerequisites: tuple[str, ...] = ()
61
+ inputs: tuple[str, ...] = ()
59
62
  fallback: str | None = None
60
63
  evidence_gap: str | None = None
61
64
  public_claim: PublicClaim | None = None
62
65
  gate_blocked: bool = False
66
+ gate_id: str | None = None
67
+ gate_state: GateState | None = None
68
+ gate_detail: str | None = None
63
69
 
64
70
 
65
71
  @dataclass(frozen=True)
@@ -91,6 +97,18 @@ class FallbackProjection:
91
97
  return {"kind": self.kind, "detail": self.detail}
92
98
 
93
99
 
100
+ @dataclass(frozen=True)
101
+ class GateProjection:
102
+ """Named gate outcome supplied by an existing gate authority."""
103
+
104
+ id: str
105
+ state: GateState
106
+ detail: str | None
107
+
108
+ def to_dict(self) -> dict[str, str | None]:
109
+ return {"id": self.id, "state": self.state, "detail": self.detail}
110
+
111
+
94
112
  @dataclass(frozen=True)
95
113
  class CapabilityReceipt:
96
114
  """Immutable, read-time capability receipt."""
@@ -99,8 +117,10 @@ class CapabilityReceipt:
99
117
  status: CapabilityStatus
100
118
  entrypoint: str | None
101
119
  prerequisites: tuple[str, ...]
120
+ inputs: tuple[str, ...]
102
121
  fallback: FallbackProjection | None
103
122
  evidence_gap: str | None
123
+ gate: GateProjection | None
104
124
 
105
125
  def to_dict(self) -> dict[str, object]:
106
126
  return {
@@ -108,9 +128,11 @@ class CapabilityReceipt:
108
128
  "status": self.status.to_dict(),
109
129
  "entrypoint": self.entrypoint,
110
130
  "prerequisites": list(self.prerequisites),
131
+ "inputs": list(self.inputs),
111
132
  "fallback": self.fallback.to_dict() if self.fallback else None,
112
133
  "publicClaim": self.status.public_claim,
113
134
  "evidenceGap": self.evidence_gap,
135
+ "gate": self.gate.to_dict() if self.gate else None,
114
136
  }
115
137
 
116
138
 
@@ -135,6 +157,16 @@ def _normalise_text(name: str, value: str | None) -> str | None:
135
157
  return value.strip()
136
158
 
137
159
 
160
+ def _normalise_gate(facts: CapabilitySourceFacts) -> GateProjection | None:
161
+ if facts.gate_id is None and facts.gate_state is None:
162
+ return None
163
+ gate_id = _normalise_text("gate-id", facts.gate_id)
164
+ if gate_id is None or facts.gate_state not in _GATE_STATE_VALUES:
165
+ raise CapabilityReceiptError("gate-invalid")
166
+ detail = _normalise_text("gate-detail", facts.gate_detail)
167
+ return GateProjection(id=gate_id, state=facts.gate_state, detail=detail)
168
+
169
+
138
170
  def _normalise_facts(facts: CapabilitySourceFacts) -> tuple[
139
171
  str,
140
172
  ImplementationState,
@@ -159,6 +191,14 @@ def _normalise_facts(facts: CapabilitySourceFacts) -> tuple[
159
191
  if normalized is None:
160
192
  raise CapabilityReceiptError("prerequisite-invalid")
161
193
  prerequisites.append(normalized)
194
+ if not isinstance(facts.inputs, tuple):
195
+ raise CapabilityReceiptError("inputs-invalid")
196
+ inputs: list[str] = []
197
+ for source_input in facts.inputs:
198
+ normalized = _normalise_text("input", source_input)
199
+ if normalized is None:
200
+ raise CapabilityReceiptError("input-invalid")
201
+ inputs.append(normalized)
162
202
  public_claim = facts.public_claim
163
203
  if public_claim is not None and (
164
204
  not isinstance(public_claim, str)
@@ -180,6 +220,7 @@ def _normalise_facts(facts: CapabilitySourceFacts) -> tuple[
180
220
  ),
181
221
  _normalise_text("entrypoint", facts.entrypoint),
182
222
  tuple(prerequisites),
223
+ tuple(inputs),
183
224
  _normalise_text("fallback", facts.fallback),
184
225
  _normalise_text("evidence-gap", facts.evidence_gap),
185
226
  public_claim,
@@ -226,10 +267,12 @@ def build_capability_receipt(
226
267
  availability,
227
268
  entrypoint,
228
269
  prerequisites,
270
+ inputs,
229
271
  fallback_text,
230
272
  supplied_gap,
231
- requested_claim,
273
+ requested_claim,
232
274
  ) = _normalise_facts(facts)
275
+ gate = _normalise_gate(facts)
233
276
  public_claim = _derive_public_claim(
234
277
  implementation=implementation,
235
278
  validation=validation,
@@ -262,6 +305,10 @@ def build_capability_receipt(
262
305
  gaps.append("surface is unsupported")
263
306
  if facts.gate_blocked or requested_claim == "blocked-by-gate":
264
307
  gaps.append("capability is blocked by gate")
308
+ if gate is not None and gate.state == "not-satisfied":
309
+ gaps.append(f"{gate.id} is not satisfied")
310
+ if gate.detail:
311
+ gaps.append(gate.detail)
265
312
  if requested_claim == "stable" and public_claim != "stable":
266
313
  gaps.append("stable public claim rejected by incomplete readiness evidence")
267
314
  if supplied_gap:
@@ -284,8 +331,8 @@ def build_capability_receipt(
284
331
  ),
285
332
  entrypoint=entrypoint,
286
333
  prerequisites=prerequisites,
334
+ inputs=inputs,
287
335
  fallback=fallback,
288
336
  evidence_gap=evidence_gap,
337
+ gate=gate,
289
338
  )
290
-
291
-