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.
- package/codex/AGENTS.md +1 -1
- package/commands/run-status.md +1 -1
- package/mcp/evidence/handoff.py +9 -1
- package/mcp/evidence/test_handoff.py +51 -0
- package/mcp/evidence/test_handoff_i18n.py +10 -0
- package/mcp/preview/i18n.py +0 -47
- package/mcp/run_console/app.js +32 -5
- package/mcp/run_console/contract.py +17 -0
- package/mcp/run_console/fixtures/point-back-recirculate.md +7 -0
- package/mcp/run_console/repair_packet.py +44 -5
- package/mcp/run_console/snapshot_builder.py +19 -10
- package/mcp/run_console/snapshot_v1.schema.json +6 -1
- package/mcp/run_console/test_contract.py +28 -0
- package/mcp/run_console/test_parity.py +21 -10
- package/mcp/run_console/test_repair_packet.py +302 -12
- package/mcp/run_console/test_snapshot_builder.py +64 -0
- package/mcp/ui_locale.py +8 -1
- package/package.json +1 -1
- package/scripts/capability_receipt.py +50 -3
- package/scripts/run_continuation.py +39 -3
- package/scripts/run_facts.py +32 -11
- package/scripts/run_status.py +9 -0
- package/scripts/status_projection.py +70 -2
- package/skills/design-baseline/SKILL.md +4 -3
- package/skills/design-baseline/scripts/design_baseline.py +58 -1
|
@@ -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"], "
|
|
169
|
-
self.assertEqual(packet["
|
|
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
|
|
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"], "
|
|
184
|
-
self.
|
|
185
|
-
self.
|
|
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
|
|
309
|
-
|
|
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("
|
|
379
|
-
self.assertIn("
|
|
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.
|
|
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
|
-
|
|
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
|
-
|