design-playbook 0.24.2 → 0.24.3
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/README.md +2 -2
- package/codex/AGENTS.md +1 -1
- package/mcp/run_console/snapshot_builder.py +169 -54
- package/mcp/run_console/test_http_server.py +52 -0
- package/mcp/run_console/test_parity.py +66 -0
- package/package.json +1 -1
- package/scripts/adapter_drift.py +114 -0
- package/scripts/adapter_markers.py +48 -0
- package/scripts/adapter_matrix.py +20 -111
- package/scripts/doctor.py +69 -39
- package/scripts/g10_design_decisions.py +9 -19
- package/scripts/generate_adapter.py +73 -52
- package/scripts/run_facts.py +15 -5
- package/scripts/run_metadata.py +20 -1
- package/scripts/validate_run.py +11 -3
package/README.md
CHANGED
|
@@ -106,11 +106,11 @@ pi install npm:pi-mcp-adapter
|
|
|
106
106
|
|
|
107
107
|
## Install on other agents
|
|
108
108
|
|
|
109
|
-
Generate adapter files for Cursor, Gemini CLI, OpenCode, Windsurf, GitHub Copilot, or any of 22 Tier-3 agents:
|
|
109
|
+
Generate adapter files for Cursor, Gemini CLI, OpenCode, Windsurf, GitHub Copilot, Zed, or any of 22 Tier-3 agents:
|
|
110
110
|
|
|
111
111
|
```bash
|
|
112
112
|
npx design-playbook init <agent> # e.g. cursor, gemini-cli, windsurf
|
|
113
|
-
npx design-playbook --list # all
|
|
113
|
+
npx design-playbook --list # all 30 agents, shows which have renderers
|
|
114
114
|
```
|
|
115
115
|
|
|
116
116
|
See the root [README](../../README.md#install-on-other-agents) for the tier table and capability notes.
|
package/codex/AGENTS.md
CHANGED
|
@@ -59,6 +59,7 @@ from design_playbook.scripts.pointback_projection import (
|
|
|
59
59
|
)
|
|
60
60
|
from design_playbook.scripts.run_facts import RunFacts, capture_run_facts
|
|
61
61
|
from design_playbook.scripts.run_metadata import (
|
|
62
|
+
PackageMetadataProjection,
|
|
62
63
|
project_limitations,
|
|
63
64
|
project_package_metadata,
|
|
64
65
|
)
|
|
@@ -156,15 +157,44 @@ class BuiltSnapshot:
|
|
|
156
157
|
|
|
157
158
|
@dataclass(frozen=True)
|
|
158
159
|
class _Capture:
|
|
159
|
-
"""One full capture of every parser input the build consumes.
|
|
160
|
+
"""One full capture of every parser input the build consumes.
|
|
161
|
+
|
|
162
|
+
The manifest and package texts ride their owner captures (T-042,
|
|
163
|
+
spec D8): ``facts.manifest_raw_text`` from the RunFacts read and
|
|
164
|
+
``product.read_state``/``product.raw_text`` from the run-metadata
|
|
165
|
+
projection — the builder never re-reads those files through a second
|
|
166
|
+
parser.
|
|
167
|
+
"""
|
|
160
168
|
|
|
161
169
|
facts: RunFacts
|
|
162
170
|
contract_text: str | None
|
|
163
171
|
contract_state: str
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
172
|
+
product: PackageMetadataProjection
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class _Projection:
|
|
177
|
+
"""Values produced by projection and consumed at assembly (T-042):
|
|
178
|
+
|
|
179
|
+
the phase-bridge fields the builder used to carry as mutable
|
|
180
|
+
``self._manifest_degraded`` / ``self._limitations`` state now flow
|
|
181
|
+
explicitly from ``_project`` to ``_assemble``.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
manifest_degraded: bool = False
|
|
185
|
+
limitations: tuple[Any, ...] = ()
|
|
186
|
+
limitations_digest: str = ""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class _ProjectionAcc:
|
|
190
|
+
"""Mutable accumulator scoped to one ``_project`` pass (T-042): the
|
|
191
|
+
phase-bridge fields it collects flow to assembly as a ``_Projection``
|
|
192
|
+
value instead of ``self``-carried state."""
|
|
193
|
+
|
|
194
|
+
def __init__(self) -> None:
|
|
195
|
+
self.manifest_degraded = False
|
|
196
|
+
self.limitations: tuple[Any, ...] = ()
|
|
197
|
+
self.limitations_digest = ""
|
|
168
198
|
|
|
169
199
|
|
|
170
200
|
@dataclass(frozen=True)
|
|
@@ -236,6 +266,61 @@ def _slug(value: str) -> str:
|
|
|
236
266
|
return value.strip().lower().replace(".", "-")
|
|
237
267
|
|
|
238
268
|
|
|
269
|
+
def _canonical(value: Any) -> Any:
|
|
270
|
+
"""JSON-stable shape of an owner-read value (T-043): sets/tuples become
|
|
271
|
+
ordered lists so digests are deterministic across processes, and
|
|
272
|
+
non-JSON objects fall back to their (dataclass-stable) repr."""
|
|
273
|
+
if isinstance(value, (set, frozenset)):
|
|
274
|
+
return sorted(str(item) for item in value)
|
|
275
|
+
if isinstance(value, (list, tuple)):
|
|
276
|
+
return [_canonical(item) for item in value]
|
|
277
|
+
if isinstance(value, dict):
|
|
278
|
+
return {
|
|
279
|
+
str(key): _canonical(item)
|
|
280
|
+
for key, item in sorted(value.items(), key=lambda kv: str(kv[0]))
|
|
281
|
+
}
|
|
282
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
283
|
+
return value
|
|
284
|
+
try:
|
|
285
|
+
json.dumps(value)
|
|
286
|
+
except (TypeError, ValueError):
|
|
287
|
+
return repr(value)
|
|
288
|
+
return value
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class _ObservedFacts:
|
|
292
|
+
"""Read-logging wrapper around ``RunFacts`` (T-043, spec D9).
|
|
293
|
+
|
|
294
|
+
Every attribute access is recorded with a canonical digest of the
|
|
295
|
+
returned value; callables are wrapped so the *call result* is logged
|
|
296
|
+
instead of the method object. Observation digests derive from this
|
|
297
|
+
log, so an owner reading a new input extends its digest without a
|
|
298
|
+
hand-maintained list.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
def __init__(self, facts: RunFacts) -> None:
|
|
302
|
+
object.__setattr__(self, "_facts", facts)
|
|
303
|
+
object.__setattr__(self, "reads", [])
|
|
304
|
+
|
|
305
|
+
def __getattribute__(self, name: str) -> Any:
|
|
306
|
+
if name in ("_facts", "reads"):
|
|
307
|
+
return object.__getattribute__(self, name)
|
|
308
|
+
value = getattr(object.__getattribute__(self, "_facts"), name)
|
|
309
|
+
reads = object.__getattribute__(self, "reads")
|
|
310
|
+
if callable(value):
|
|
311
|
+
def _logged(*args: Any, **kwargs: Any) -> Any:
|
|
312
|
+
result = value(*args, **kwargs)
|
|
313
|
+
reads.append((
|
|
314
|
+
f"{name}()",
|
|
315
|
+
_digest_json(_canonical(args)),
|
|
316
|
+
_digest_json(_canonical(result)),
|
|
317
|
+
))
|
|
318
|
+
return result
|
|
319
|
+
return _logged
|
|
320
|
+
reads.append((name, _digest_json(_canonical(value))))
|
|
321
|
+
return value
|
|
322
|
+
|
|
323
|
+
|
|
239
324
|
class _Builder:
|
|
240
325
|
"""One snapshot build over one selected run root."""
|
|
241
326
|
|
|
@@ -253,18 +338,15 @@ class _Builder:
|
|
|
253
338
|
}
|
|
254
339
|
self._records: dict[str, dict[str, Any]] = {}
|
|
255
340
|
self._drafts: list[_DraftAssertion] = []
|
|
256
|
-
self._manifest_degraded = False
|
|
257
341
|
self._artifact_sources: dict[str, RegisteredSource] = {}
|
|
258
342
|
self._artifact_hashes_observed: dict[str, str | None] = {}
|
|
259
343
|
self._artifact_hashes_verified: dict[str, str | None] = {}
|
|
260
|
-
self._limitations: tuple[Any, ...] = ()
|
|
261
|
-
self._limitations_digest = ""
|
|
262
344
|
|
|
263
345
|
# -- orchestration ------------------------------------------------
|
|
264
346
|
|
|
265
347
|
def build(self, mid_build_hook: Callable[[], None] | None) -> dict[str, Any]:
|
|
266
348
|
capture = self._capture()
|
|
267
|
-
self._project(capture)
|
|
349
|
+
projection = self._project(capture)
|
|
268
350
|
if mid_build_hook is not None:
|
|
269
351
|
mid_build_hook()
|
|
270
352
|
verify = self._capture()
|
|
@@ -272,7 +354,7 @@ class _Builder:
|
|
|
272
354
|
relpath: self._read_artifact_hash(relpath)
|
|
273
355
|
for relpath in self._artifact_sources
|
|
274
356
|
}
|
|
275
|
-
document = self._assemble(capture, verify)
|
|
357
|
+
document = self._assemble(capture, verify, projection)
|
|
276
358
|
# The build is atomic: it returns only a contract-valid document.
|
|
277
359
|
validate_snapshot(document)
|
|
278
360
|
return document
|
|
@@ -284,20 +366,12 @@ class _Builder:
|
|
|
284
366
|
contract_text, contract_state = self._read_optional_text(
|
|
285
367
|
self._run_root / "contract-bind.json"
|
|
286
368
|
)
|
|
287
|
-
|
|
288
|
-
self._run_root / "evidence" / "manifest.jsonl"
|
|
289
|
-
)
|
|
290
|
-
package_text, package_state = self._read_optional_text(
|
|
291
|
-
self._package_root / ".claude-plugin" / "plugin.json"
|
|
292
|
-
)
|
|
369
|
+
product = project_package_metadata(self._package_root)
|
|
293
370
|
return _Capture(
|
|
294
371
|
facts=facts,
|
|
295
372
|
contract_text=contract_text,
|
|
296
373
|
contract_state=contract_state,
|
|
297
|
-
|
|
298
|
-
manifest_state=manifest_state,
|
|
299
|
-
package_text=package_text,
|
|
300
|
-
package_state=package_state,
|
|
374
|
+
product=product,
|
|
301
375
|
)
|
|
302
376
|
|
|
303
377
|
def _read_optional_text(self, path: Path) -> tuple[str | None, str]:
|
|
@@ -320,8 +394,8 @@ class _Builder:
|
|
|
320
394
|
|
|
321
395
|
# -- read states --------------------------------------------------
|
|
322
396
|
|
|
323
|
-
def _manifest_state(self, facts: RunFacts,
|
|
324
|
-
if
|
|
397
|
+
def _manifest_state(self, facts: RunFacts, projection: _Projection) -> str:
|
|
398
|
+
if projection.manifest_degraded:
|
|
325
399
|
# The Manifest was read, but a declared binding cannot be
|
|
326
400
|
# established (missing or escaping artifact, rejected artifact
|
|
327
401
|
# name, unreadable artifact bytes): the manifest record maps to a
|
|
@@ -330,12 +404,13 @@ class _Builder:
|
|
|
330
404
|
for error in facts.read_errors:
|
|
331
405
|
if error.artifact == "manifest":
|
|
332
406
|
return "unreadable" if error.code == "unreadable" else "malformed"
|
|
333
|
-
return
|
|
407
|
+
return facts.artifact_state("manifest")
|
|
334
408
|
|
|
335
409
|
# -- projection (owner seams over captured inputs) -----------------
|
|
336
410
|
|
|
337
|
-
def _project(self, capture: _Capture) ->
|
|
411
|
+
def _project(self, capture: _Capture) -> _Projection:
|
|
338
412
|
facts = capture.facts
|
|
413
|
+
acc = _ProjectionAcc()
|
|
339
414
|
|
|
340
415
|
# identity.run: the selected-session fact from the registry.
|
|
341
416
|
self._draft(
|
|
@@ -345,8 +420,9 @@ class _Builder:
|
|
|
345
420
|
{"runId": self._registry.run_id, "label": None},
|
|
346
421
|
)
|
|
347
422
|
|
|
348
|
-
# identity.product: package metadata owner
|
|
349
|
-
|
|
423
|
+
# identity.product: package metadata owner (read during capture —
|
|
424
|
+
# the owner's captured text also feeds the observation hash).
|
|
425
|
+
product = capture.product
|
|
350
426
|
if product.availability == "known" and product.value is not None:
|
|
351
427
|
self._draft(
|
|
352
428
|
"identity.product",
|
|
@@ -527,7 +603,7 @@ class _Builder:
|
|
|
527
603
|
)
|
|
528
604
|
|
|
529
605
|
# evaluation: point-back owner over the captured point-back text.
|
|
530
|
-
self._project_evaluation(facts, pointback_state, spec_criteria)
|
|
606
|
+
self._project_evaluation(facts, pointback_state, spec_criteria, acc)
|
|
531
607
|
|
|
532
608
|
# nextActions: next-action owner over the same captured facts.
|
|
533
609
|
action = project_next_action(
|
|
@@ -561,6 +637,11 @@ class _Builder:
|
|
|
561
637
|
action_result,
|
|
562
638
|
)
|
|
563
639
|
# The owner emits no alternatives; the empty list is owner-known.
|
|
640
|
+
return _Projection(
|
|
641
|
+
manifest_degraded=acc.manifest_degraded,
|
|
642
|
+
limitations=acc.limitations,
|
|
643
|
+
limitations_digest=acc.limitations_digest,
|
|
644
|
+
)
|
|
564
645
|
|
|
565
646
|
def _project_contract(self, capture: _Capture) -> None:
|
|
566
647
|
if capture.contract_state == "missing":
|
|
@@ -632,6 +713,7 @@ class _Builder:
|
|
|
632
713
|
facts: RunFacts,
|
|
633
714
|
pointback_state: str,
|
|
634
715
|
spec_criteria: tuple[Any, ...],
|
|
716
|
+
acc: _ProjectionAcc,
|
|
635
717
|
) -> None:
|
|
636
718
|
criteria_ids = tuple(c.criterion_id for c in spec_criteria)
|
|
637
719
|
verdict_result: str | None = None
|
|
@@ -668,7 +750,7 @@ class _Builder:
|
|
|
668
750
|
"complete": pointback.coverage.complete,
|
|
669
751
|
}
|
|
670
752
|
for evaluation in pointback.criteria:
|
|
671
|
-
bindings = self._evidence_bindings(facts, criteria_ids, evaluation)
|
|
753
|
+
bindings = self._evidence_bindings(facts, criteria_ids, evaluation, acc)
|
|
672
754
|
evaluated.append((evaluation, bindings))
|
|
673
755
|
findings = list(pointback.findings)
|
|
674
756
|
|
|
@@ -730,20 +812,20 @@ class _Builder:
|
|
|
730
812
|
)
|
|
731
813
|
|
|
732
814
|
# limitations: run-metadata owner (never caller-authored prose).
|
|
733
|
-
|
|
815
|
+
acc.limitations = project_limitations(
|
|
734
816
|
owner_unmapped_assertion_ids=tuple(sorted(unmapped_ids))
|
|
735
817
|
)
|
|
736
|
-
|
|
818
|
+
acc.limitations_digest = _digest_json(
|
|
737
819
|
[
|
|
738
820
|
{
|
|
739
821
|
"code": item.code,
|
|
740
822
|
"summary": item.summary,
|
|
741
823
|
"affectsAssertionIds": list(item.affects_assertion_ids),
|
|
742
824
|
}
|
|
743
|
-
for item in
|
|
825
|
+
for item in acc.limitations
|
|
744
826
|
]
|
|
745
827
|
)
|
|
746
|
-
for limitation in
|
|
828
|
+
for limitation in acc.limitations:
|
|
747
829
|
self._draft(
|
|
748
830
|
f"limitations.items.{_slug(limitation.code)}",
|
|
749
831
|
(_REF_LIMITATIONS,),
|
|
@@ -756,7 +838,11 @@ class _Builder:
|
|
|
756
838
|
)
|
|
757
839
|
|
|
758
840
|
def _evidence_bindings(
|
|
759
|
-
self,
|
|
841
|
+
self,
|
|
842
|
+
facts: RunFacts,
|
|
843
|
+
criteria_ids: tuple[str, ...],
|
|
844
|
+
evaluation: Any,
|
|
845
|
+
acc: _ProjectionAcc,
|
|
760
846
|
) -> list[dict[str, Any]]:
|
|
761
847
|
"""Project the Manifest evidence binding for one ledger row.
|
|
762
848
|
|
|
@@ -777,18 +863,18 @@ class _Builder:
|
|
|
777
863
|
)
|
|
778
864
|
if any(finding.rule_id in _UNBINDABLE_RULES for finding in g6_findings):
|
|
779
865
|
if any(finding.rule_id in _DEGRADING_RULES for finding in g6_findings):
|
|
780
|
-
|
|
866
|
+
acc.manifest_degraded = True
|
|
781
867
|
return []
|
|
782
868
|
leaf = token[len("evidence/"):]
|
|
783
869
|
try:
|
|
784
870
|
source = self._registry.derive_evidence_artifact_source(leaf)
|
|
785
871
|
except SourceRegistryError:
|
|
786
|
-
|
|
872
|
+
acc.manifest_degraded = True
|
|
787
873
|
return []
|
|
788
874
|
relpath = source.capture_targets[0]
|
|
789
875
|
artifact_hash = self._read_artifact_hash(relpath)
|
|
790
876
|
if artifact_hash is None:
|
|
791
|
-
|
|
877
|
+
acc.manifest_degraded = True
|
|
792
878
|
return []
|
|
793
879
|
self._artifact_sources[relpath] = source
|
|
794
880
|
self._sources[source.source_ref] = source
|
|
@@ -865,8 +951,37 @@ class _Builder:
|
|
|
865
951
|
"facts": [fact.code for fact in snapshot.facts],
|
|
866
952
|
}
|
|
867
953
|
|
|
954
|
+
def _owner_read_digest(self, capture: _Capture, *, chain: str) -> str:
|
|
955
|
+
"""Digest what the projection owner actually read (T-043, spec D9).
|
|
956
|
+
|
|
957
|
+
The owner is re-invoked over a read-logging wrapper of the captured
|
|
958
|
+
facts; the log (accessed attribute + canonical digest of each value,
|
|
959
|
+
plus the preview input passed by argument) is the observation
|
|
960
|
+
digest. Extending an owner with a new input extends its digest with
|
|
961
|
+
no hand-maintained input list to forget.
|
|
962
|
+
"""
|
|
963
|
+
facts = capture.facts
|
|
964
|
+
proxy = _ObservedFacts(facts)
|
|
965
|
+
proxy.reads.append(
|
|
966
|
+
("preview_snapshot", _digest_json(self._preview_digest(facts.preview)))
|
|
967
|
+
)
|
|
968
|
+
states = inspect_run(
|
|
969
|
+
self._run_root, preview_snapshot=facts.preview, run_facts=proxy
|
|
970
|
+
)
|
|
971
|
+
if chain == "next-action":
|
|
972
|
+
project_next_action(
|
|
973
|
+
states,
|
|
974
|
+
self._run_root,
|
|
975
|
+
preview_snapshot=facts.preview,
|
|
976
|
+
run_facts=proxy,
|
|
977
|
+
)
|
|
978
|
+
return _digest_json(proxy.reads)
|
|
979
|
+
|
|
868
980
|
def _observations(
|
|
869
|
-
self,
|
|
981
|
+
self,
|
|
982
|
+
capture: _Capture,
|
|
983
|
+
artifact_hashes: dict[str, str | None],
|
|
984
|
+
projection: _Projection,
|
|
870
985
|
) -> dict[str, _Observation]:
|
|
871
986
|
facts = capture.facts
|
|
872
987
|
observations: dict[str, _Observation] = {}
|
|
@@ -888,9 +1003,9 @@ class _Builder:
|
|
|
888
1003
|
)
|
|
889
1004
|
add(
|
|
890
1005
|
self._registry.source("package.metadata"),
|
|
891
|
-
capture.
|
|
892
|
-
_digest_text(capture.
|
|
893
|
-
if capture.
|
|
1006
|
+
capture.product.read_state,
|
|
1007
|
+
_digest_text(capture.product.raw_text)
|
|
1008
|
+
if capture.product.raw_text is not None
|
|
894
1009
|
else None,
|
|
895
1010
|
)
|
|
896
1011
|
plan_state = facts.artifact_state("plan")
|
|
@@ -915,7 +1030,7 @@ class _Builder:
|
|
|
915
1030
|
add(
|
|
916
1031
|
self._registry.source("execution.stage-registry"),
|
|
917
1032
|
"complete",
|
|
918
|
-
|
|
1033
|
+
self._owner_read_digest(capture, chain="stage-registry"),
|
|
919
1034
|
)
|
|
920
1035
|
add(
|
|
921
1036
|
self._registry.source("execution.preview"),
|
|
@@ -931,29 +1046,23 @@ class _Builder:
|
|
|
931
1046
|
if pointback_state == "complete"
|
|
932
1047
|
else None,
|
|
933
1048
|
)
|
|
934
|
-
manifest_state = self._manifest_state(facts,
|
|
1049
|
+
manifest_state = self._manifest_state(facts, projection)
|
|
935
1050
|
add(
|
|
936
1051
|
self._registry.source("evaluation.manifest"),
|
|
937
1052
|
manifest_state,
|
|
938
|
-
_digest_text(
|
|
1053
|
+
_digest_text(facts.manifest_raw_text)
|
|
939
1054
|
if manifest_state == "complete"
|
|
940
1055
|
else None,
|
|
941
1056
|
)
|
|
942
1057
|
add(
|
|
943
1058
|
self._registry.source("run.next-action"),
|
|
944
1059
|
"complete",
|
|
945
|
-
|
|
946
|
-
[
|
|
947
|
-
sorted(facts.existing_paths),
|
|
948
|
-
facts.pointback_text,
|
|
949
|
-
self._preview_digest(facts.preview),
|
|
950
|
-
]
|
|
951
|
-
),
|
|
1060
|
+
self._owner_read_digest(capture, chain="next-action"),
|
|
952
1061
|
)
|
|
953
1062
|
add(
|
|
954
1063
|
self._registry.source("run.limitations"),
|
|
955
1064
|
"complete",
|
|
956
|
-
|
|
1065
|
+
projection.limitations_digest,
|
|
957
1066
|
)
|
|
958
1067
|
for relpath, source in self._artifact_sources.items():
|
|
959
1068
|
hash_value = artifact_hashes.get(relpath)
|
|
@@ -1083,9 +1192,15 @@ class _Builder:
|
|
|
1083
1192
|
finalized.sort(key=lambda assertion: assertion["id"])
|
|
1084
1193
|
return finalized
|
|
1085
1194
|
|
|
1086
|
-
def _assemble(
|
|
1087
|
-
|
|
1088
|
-
|
|
1195
|
+
def _assemble(
|
|
1196
|
+
self, capture: _Capture, verify: _Capture, projection: _Projection
|
|
1197
|
+
) -> dict[str, Any]:
|
|
1198
|
+
observed = self._observations(
|
|
1199
|
+
capture, self._artifact_hashes_observed, projection
|
|
1200
|
+
)
|
|
1201
|
+
verified = self._observations(
|
|
1202
|
+
verify, self._artifact_hashes_verified, projection
|
|
1203
|
+
)
|
|
1089
1204
|
self._finalize_records(observed, verified)
|
|
1090
1205
|
assertions = self._finalize_assertions()
|
|
1091
1206
|
items = [self._records[ref] for ref in sorted(self._records)]
|
|
@@ -985,5 +985,57 @@ class ConsoleCLITest(unittest.TestCase):
|
|
|
985
985
|
self.assertNotIn(str(self.base), err.getvalue())
|
|
986
986
|
|
|
987
987
|
|
|
988
|
+
class GracefulTeardownTest(_ServerTestCase):
|
|
989
|
+
"""T-044 (spec D10a): the flush → half-close → drain → close teardown
|
|
990
|
+
must deliver the full response and a clean close even when a POST
|
|
991
|
+
leaves body bytes unread in the receive buffer. This is the Windows
|
|
992
|
+
TCP-reset defect (client WinError 10053 mid-read) that commit 9065784
|
|
993
|
+
fixed — this test pins it so it cannot regress silently."""
|
|
994
|
+
|
|
995
|
+
def test_post_with_unread_body_gets_full_response_and_clean_eof(self) -> None:
|
|
996
|
+
# No Content-Length: the handler reads an empty body and responds;
|
|
997
|
+
# the stray bytes stay in the receive buffer past the response.
|
|
998
|
+
request = (
|
|
999
|
+
f"POST /api/v1/snapshot HTTP/1.1\r\n"
|
|
1000
|
+
f"Host: {self.server.authority}\r\n"
|
|
1001
|
+
f"Origin: {self.server.origin}\r\n"
|
|
1002
|
+
f"Authorization: Bearer {self.token}\r\n"
|
|
1003
|
+
f"Connection: close\r\n\r\n"
|
|
1004
|
+
).encode() + b"stray-body-bytes-that-are-never-declared"
|
|
1005
|
+
with socket.create_connection(
|
|
1006
|
+
(self.server.bind_host, self.server.port), timeout=5
|
|
1007
|
+
) as sock:
|
|
1008
|
+
sock.sendall(request)
|
|
1009
|
+
# Let the server respond and tear down before the client reads:
|
|
1010
|
+
# with a reset-style teardown the RST poisons the socket while
|
|
1011
|
+
# the client is idle, and the first read dies with it; with the
|
|
1012
|
+
# graceful teardown the client gets the response and clean EOF.
|
|
1013
|
+
time.sleep(0.3)
|
|
1014
|
+
chunks = []
|
|
1015
|
+
while True:
|
|
1016
|
+
try:
|
|
1017
|
+
chunk = sock.recv(65536)
|
|
1018
|
+
except socket.timeout:
|
|
1019
|
+
self.fail("server never closed the connection")
|
|
1020
|
+
except OSError as exc: # includes ConnectionResetError / 10053
|
|
1021
|
+
self.fail(f"connection reset mid-read (teardown regression): {exc!r}")
|
|
1022
|
+
if not chunk:
|
|
1023
|
+
break
|
|
1024
|
+
chunks.append(chunk)
|
|
1025
|
+
raw = b"".join(chunks)
|
|
1026
|
+
self.assertTrue(raw.startswith(b"HTTP/1.1 "), raw[:40])
|
|
1027
|
+
status = int(raw.split(b" ", 2)[1])
|
|
1028
|
+
self.assertTrue(400 <= status < 500, raw[:120]) # bounded 4xx rejection
|
|
1029
|
+
# The declared response body is complete: the framing length the
|
|
1030
|
+
# server sent is fully received before EOF.
|
|
1031
|
+
header, _, body = raw.partition(b"\r\n\r\n")
|
|
1032
|
+
declared = None
|
|
1033
|
+
for line in header.split(b"\r\n"):
|
|
1034
|
+
if line.lower().startswith(b"content-length:"):
|
|
1035
|
+
declared = int(line.split(b":", 1)[1].strip())
|
|
1036
|
+
self.assertIsNotNone(declared, f"no Content-Length in: {header!r}")
|
|
1037
|
+
self.assertEqual(len(body), declared)
|
|
1038
|
+
|
|
1039
|
+
|
|
988
1040
|
if __name__ == "__main__":
|
|
989
1041
|
unittest.main()
|
|
@@ -1059,5 +1059,71 @@ class BoundaryScanTest(unittest.TestCase):
|
|
|
1059
1059
|
self.assertEqual(hits, [], f"{name} contains forbidden calls")
|
|
1060
1060
|
|
|
1061
1061
|
|
|
1062
|
+
class TestOwnerReadSetDigests(unittest.TestCase):
|
|
1063
|
+
"""T-043 (spec D9): observation digests derive from what the owner
|
|
1064
|
+
actually read. A source the owner consumes but the retired hand-maintained
|
|
1065
|
+
digest lists ignored must now turn dependent assertions stale."""
|
|
1066
|
+
|
|
1067
|
+
def setUp(self) -> None:
|
|
1068
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
1069
|
+
self.addCleanup(self._tmp.cleanup)
|
|
1070
|
+
self.base = Path(self._tmp.name)
|
|
1071
|
+
self.run_root = _make_root(self.base, "run-readset")
|
|
1072
|
+
|
|
1073
|
+
def _build(self, hook=None):
|
|
1074
|
+
return _build(self.run_root, mid_build_hook=hook)
|
|
1075
|
+
|
|
1076
|
+
def test_no_mutation_builds_current(self) -> None:
|
|
1077
|
+
# The read-log digests must be deterministic across the two capture
|
|
1078
|
+
# passes: no mutation, no false "changed".
|
|
1079
|
+
document = self._build().document
|
|
1080
|
+
self.assertEqual(validate_snapshot(document), document)
|
|
1081
|
+
self.assertEqual(document["identity"]["snapshot"]["buildState"], "current")
|
|
1082
|
+
|
|
1083
|
+
def test_mid_build_baseline_content_change_stales_next_action(self) -> None:
|
|
1084
|
+
# design-baseline/state.json content is a next-action owner input
|
|
1085
|
+
# that the retired digest list never hashed (the staleness hole).
|
|
1086
|
+
_write(
|
|
1087
|
+
self.run_root,
|
|
1088
|
+
"design-baseline/state.json",
|
|
1089
|
+
json.dumps({"status": "ready"}),
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
def mutate() -> None:
|
|
1093
|
+
_write(
|
|
1094
|
+
self.run_root,
|
|
1095
|
+
"design-baseline/state.json",
|
|
1096
|
+
json.dumps({"status": "needs_confirmation"}),
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
document = self._build(hook=mutate).document
|
|
1100
|
+
self.assertEqual(validate_snapshot(document), document)
|
|
1101
|
+
record = _source(document, "source.run-status")
|
|
1102
|
+
self.assertEqual(record["freshness"], "changed")
|
|
1103
|
+
action = _assertion(document, "next-actions.primary")
|
|
1104
|
+
self.assertEqual(action["availability"], "stale")
|
|
1105
|
+
self.assertEqual(action["reason"]["code"], "source-changed-during-build")
|
|
1106
|
+
self.assertEqual(document["identity"]["snapshot"]["buildState"], "degraded")
|
|
1107
|
+
|
|
1108
|
+
def test_mid_build_external_fill_appearance_stales_stage_registry(self) -> None:
|
|
1109
|
+
# A fill artifact outside the run root is stage-registry input that
|
|
1110
|
+
# the retired digest (existing_paths only) never covered.
|
|
1111
|
+
external_path = self.base / "_ext-fill.md"
|
|
1112
|
+
self.assertFalse(external_path.exists())
|
|
1113
|
+
plan_text = (self.run_root / "plan.md").read_text(encoding="utf-8")
|
|
1114
|
+
_write(self.run_root, "plan.md", plan_text + "\nfill: ../_ext-fill.md\n")
|
|
1115
|
+
|
|
1116
|
+
def hook() -> None:
|
|
1117
|
+
external_path.write_text("fill body\n", encoding="utf-8")
|
|
1118
|
+
|
|
1119
|
+
document = self._build(hook=hook).document
|
|
1120
|
+
self.assertEqual(validate_snapshot(document), document)
|
|
1121
|
+
record = _source(document, "source.run-facts")
|
|
1122
|
+
self.assertEqual(record["freshness"], "changed")
|
|
1123
|
+
progress = _assertion(document, "execution.progress")
|
|
1124
|
+
self.assertEqual(progress["availability"], "stale")
|
|
1125
|
+
self.assertEqual(document["identity"]["snapshot"]["buildState"], "degraded")
|
|
1126
|
+
|
|
1127
|
+
|
|
1062
1128
|
if __name__ == "__main__": # pragma: no cover
|
|
1063
1129
|
unittest.main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "design-playbook",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.3",
|
|
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",
|