design-playbook 0.24.1 → 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 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 29 agents, shows which have renderers
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
@@ -1,4 +1,4 @@
1
- <!-- generated-by design-playbook v0.24.1 -->
1
+ <!-- generated-by design-playbook v0.24.3 -->
2
2
  # design-playbook for Codex
3
3
 
4
4
  ## Install (path of record)
@@ -26,3 +26,6 @@ Registry: `skills/design-playbook/references/rules.md`, full catalog (P3 run:
26
26
  | DECIDE-01@1 | applicable | - | clear | 选中方向为全局 run console 构成重组(candidates/console-region.html 草图 + preview round 1/2 用户确认),非未审视的默认外观收敛 | DD-0001 理由可回溯 l1.scenes(切页续读)与 PERF-01 比较轴及经用户确认的布局段突破;DD-0002 理由可回溯 l6.c4(跨视图状态闭环)与运行中心第一步方向——均引用 brief 具体事实 | 常规方向经比较矩阵沿 brief 轴证成,非未审视默认;无基线默认方向身份声明 | - |
27
27
 
28
28
  注:2026-08-28 注册批(COPY-01/02/03、A11Y-02、CRAFT-09/10、DECIDE-01)晚于本 run 存档;按三态谓词补记——blocked 行在理由列点名缺失的证据面;DECIDE-01 依归档内可读的决策报告求值为 applicable。
29
+ | STATE-01@1 | applicable | - | clear | 运行 feed 条目级进度持续更新(进行中项逐条指名) | Fill 声明条目级进度 + evidence 进度语义 | - | - |
30
+ | STATE-02@1 | applicable | - | clear | 「无运行时 feed 不渲染」为 spec 声明边界:无 feed 时控制台不呈现空集合区 | Fill 声明边界条目(L 边界对齐) | 例外成立——空呈现即 spec 声明的产品行为 | - |
31
+ | STATE-03@1 | applicable | - | clear | 暂停失败 toast(role=alert)+ 失败项保留并支持圈选批量重试 | Fill 声明(失败 toast + 批量重试操作条) | - | - |
@@ -26,3 +26,6 @@ Registry: `skills/design-playbook/references/rules.md`, full catalog (P2 run). S
26
26
  | DECIDE-01@1 | applicable | - | clear | 选中方向为按周命名模式(compare 档轻量比较),不落入 2026-08 记录的自默认外观样貌;DD-0001 为 record 档,不在本规则范围 | DD-0002 理由可回溯 l1.target_user 比较轴(周频归档场景下检索是主任务)——引用 brief 具体事实而非通用措辞 | 常规方向经比较矩阵沿 brief 轴证成,非未审视默认;无基线默认方向身份声明 | - |
27
27
 
28
28
  注:2026-08-28 注册批(COPY-01/02/03、A11Y-02、CRAFT-09/10、DECIDE-01)晚于本 run 存档;按三态谓词补记——blocked 行在理由列点名缺失的证据面;DECIDE-01 依归档内可读的决策报告求值为 applicable。
29
+ | STATE-01@1 | applicable | - | clear | 批量导出触发钮 busy/disabled 守卫(控件自身标签即操作名,pending 区域限于触发钮) | Fill 声明 busy state while exporting + busy/disabled guard | 单帧完成例外不成立(批量导出为长操作) | - |
30
+ | STATE-02@1 | blocked | 静态夹具无数据源,首载空/筛选空均不可达 | - | 表格列表区声明在场,空态分支缺席 | 无数据源可供触发空态 | - | 接入数据面后补评首载空与筛选空呈现 |
31
+ | STATE-03@1 | applicable | - | clear | cap 上限 toast(role=alert + 可读名称含上限值) | Fill 声明 toast 承载上限拒绝 | 上限拒绝为失败路径的可恢复呈现(出口=调整范围后再导出) | - |
@@ -26,3 +26,6 @@ Registry: `skills/design-playbook/references/rules.md`. Seven-column rows; after
26
26
  | DECIDE-01@1 | not-applicable | 决策报告仅含 record 档 DD-0101,无 compare/explore 档方向决策条目 | - | - | - | - | - |
27
27
 
28
28
  注:2026-08-28 注册批(COPY-01/02/03、A11Y-02、CRAFT-09/10、DECIDE-01)晚于本 run 存档;按三态谓词补记,blocked 行在理由列记缺失证据面。
29
+ | STATE-01@1 | applicable | - | clear | 导出触发钮 busy/disabled 守卫(自前次 run 保留) | Fill 声明 busy/disabled guard 保留 | 单帧完成例外不成立 | - |
30
+ | STATE-02@1 | applicable | - | clear | 空数据集预检:数据集为空时触发钮禁用 + toast「无可选行」(指名原因) | Fill 声明 empty-blocked pre-check(R4 修复) | 例外不成立——空数据呈现明确指名原因与出口 | - |
31
+ | STATE-03@1 | applicable | - | clear | cap 上限 toast(自前次 run 保留,role=alert) | Fill 声明 cap-limit toast 保留 | - | - |
@@ -26,3 +26,6 @@ Registry: `skills/design-playbook/references/rules.md`, full catalog (P3 run:
26
26
  | DECIDE-01@1 | applicable | - | clear | 选中方向为启用既有 status region 收纳导出任务(candidates/B.html 草图 + preview round 1/2 用户确认),非未审视的默认外观收敛 | DD-0003 理由可回溯 l1.scenes(导出中切页全局可查)与 PERF-01 比较轴;DD-0004 理由可回溯 l6.c2(跨视图状态闭环)与基线 status region 惯例声明——均引用 brief 具体事实 | 常规方向经比较矩阵沿 brief 轴证成,非未审视默认;基线声明的是 status region 惯例而非默认外观身份 | - |
27
27
 
28
28
  注:2026-08-28 注册批(COPY-01/02/03、A11Y-02、CRAFT-09/10、DECIDE-01)晚于本 run 存档;按三态谓词补记——blocked 行在理由列点名缺失的证据面;DECIDE-01 依归档内可读的决策报告求值为 applicable。
29
+ | STATE-01@1 | applicable | - | clear | 长导出条目级进度持续更新(30s 窗口 5 次采样) | evidence/L6.1-status-trace.json 进度采样序列 | - | - |
30
+ | STATE-02@1 | blocked | 候选页为静态导出面,无数据供给集合面,空态不可达 | - | 无空态分支呈现 | 无数据源 | - | 接入数据面后补评 |
31
+ | STATE-03@1 | not-applicable | 本 run 升档面未声明异步失败路径(进度轨迹为唯一运行时面) | - | - | 候选页无可触发失败分支 | - | - |
@@ -139,6 +139,29 @@ class RunConsoleRequestHandler(http.server.BaseHTTPRequestHandler):
139
139
  def log_message(self, format: str, *args: object) -> None:
140
140
  """Never log: no URL, token, or locator may reach any log."""
141
141
 
142
+ def finish(self) -> None:
143
+ """Graceful teardown: half-close, drain, then close.
144
+
145
+ A client body left unread in the receive buffer (e.g. a POST whose
146
+ Content-Length is absent: the stray bytes parse as garbage request
147
+ lines and the connection must die) turns a plain close() into a TCP
148
+ reset on Windows — the client gets WinError 10053 mid-read instead
149
+ of the response it was sent. Flush the response, half-close (FIN),
150
+ drain the peer's remaining bytes, then close, so teardown is never
151
+ a reset.
152
+ """
153
+ super().finish()
154
+ try:
155
+ self.connection.shutdown(socket.SHUT_WR)
156
+ except OSError:
157
+ pass
158
+ try:
159
+ self.connection.settimeout(0.2)
160
+ while self.connection.recv(65536):
161
+ pass
162
+ except OSError:
163
+ pass
164
+
142
165
  # -- dispatch ------------------------------------------------------
143
166
 
144
167
  def do_GET(self) -> None: # noqa: N802
@@ -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
- manifest_text: str | None
165
- manifest_state: str
166
- package_text: str | None
167
- package_state: str
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
- manifest_text, manifest_state = self._read_optional_text(
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
- manifest_text=manifest_text,
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, capture: _Capture) -> str:
324
- if self._manifest_degraded:
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 capture.manifest_state
407
+ return facts.artifact_state("manifest")
334
408
 
335
409
  # -- projection (owner seams over captured inputs) -----------------
336
410
 
337
- def _project(self, capture: _Capture) -> None:
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
- product = project_package_metadata(self._package_root)
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
- self._limitations = project_limitations(
815
+ acc.limitations = project_limitations(
734
816
  owner_unmapped_assertion_ids=tuple(sorted(unmapped_ids))
735
817
  )
736
- self._limitations_digest = _digest_json(
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 self._limitations
825
+ for item in acc.limitations
744
826
  ]
745
827
  )
746
- for limitation in self._limitations:
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, facts: RunFacts, criteria_ids: tuple[str, ...], evaluation: Any
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
- self._manifest_degraded = True
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
- self._manifest_degraded = True
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
- self._manifest_degraded = True
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, capture: _Capture, artifact_hashes: dict[str, str | None]
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.package_state,
892
- _digest_text(capture.package_text)
893
- if capture.package_text is not None
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
- _digest_json(sorted(facts.existing_paths)),
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, capture)
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(capture.manifest_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
- _digest_json(
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
- self._limitations_digest,
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(self, capture: _Capture, verify: _Capture) -> dict[str, Any]:
1087
- observed = self._observations(capture, self._artifact_hashes_observed)
1088
- verified = self._observations(verify, self._artifact_hashes_verified)
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.1",
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",