design-playbook 0.20.0 → 0.20.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.
@@ -2,7 +2,7 @@
2
2
  {"event": "answered", "question_id": "Q1", "answer": "运行状态跨页可查、全局控制可用、失败可批量治理", "ts": "2026-08-14T09:02:00Z"}
3
3
  {"event": "asked", "question_id": "Q9", "batch": 2, "tier": "T3", "text": "顶栏计数区升级为全局运行控制台——视觉方向(构成级)?", "impact": "D3 decision-report(成形只登记路由,D3 裁决)", "ts": "2026-08-14T09:03:00Z"}
4
4
  {"event": "assumption_staged", "field": "sim.control_scope", "tier": "T2", "reason": "全局暂停的作用范围未答", "risk": "误伤手动单次重试", "fallback": "全局暂停/恢复不作用于手动单次重试", "ts": "2026-08-14T09:04:00Z"}
5
- {"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": ["l1.goal 修订(supersedes D-0001)", "l6.c4-c6 表述"], "ts": "2026-08-14T09:08:00Z"}
5
+ {"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": [{"field": "l1.goal", "value": "修订(supersedes D-0001)"}, "l6.c4", "l6.c5", "l6.c6"], "ts": "2026-08-14T09:08:00Z"}
6
6
  {"event": "confirm_presented", "batch": "CP-C", "kind": "assumption", "items": ["sim.control_scope"], "ts": "2026-08-14T09:08:00Z"}
7
7
  {"event": "item_confirmed", "batch": "CP-A", "field": "l1.goal", "ts": "2026-08-14T09:12:00Z"}
8
8
  {"event": "item_confirmed", "batch": "CP-A", "field": "l6.c4", "ts": "2026-08-14T09:12:00Z"}
@@ -1,15 +1,10 @@
1
- # User-test session notes L6.1 supplementary (quarantined)
1
+ # Synthetic user-test examplenot evidence
2
2
 
3
- Round 1 usability session, 2026-08-14. Facilitated weekly-report export
4
- task with 3 operations participants (convenience sample).
3
+ Schema/example material only. No real human-subject session occurred, no
4
+ participant observations or personal data are represented, and this file is
5
+ not registered in `manifest.jsonl`.
5
6
 
6
- Observed behavior: 2 of 3 participants first looked for the export entry
7
- in the global toolbar, then used the inline batch export after a hint.
8
-
9
- Status: **unusable as evidence** the session ran before the consent /
10
- anonymisation / retention record was completed, so the manifest entry
11
- carries `method: user-test` with `population` but no `ethics` key. Per the
12
- method-semantics gate this entry is quarantined as blocked evidence: it
13
- cannot support any judgment (the L6.1 pass rests on
14
- `L6.1-export-trace.json`, not on these notes). Complete the ethics record
15
- in a newer manifest entry to make the session citable.
7
+ The scenario illustrates what a user-test note might look like during method
8
+ semantics testing. It must not support a run judgment or any claim about user
9
+ behavior. Tests that exercise missing `ethics` construct their own temporary
10
+ manifest entry rather than treating this file as evidence.
@@ -5,4 +5,3 @@
5
5
  {"criterion": "L6.2", "capture": {"type": "a11y tree", "provider": "manual", "state": "cap-blocked-r2", "actions": ["select over-cap range", "open export dialog (post R4 fix)"], "schemaVersion": 1, "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}}, "artifact": "L6.2-a11y-tree-r2.json", "observed_state": "cap-blocked-r2", "result": "captured", "ts": "2026-08-14T11:04:30Z", "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}, "method": "runtime-observation", "observation": "toast 节点带 role=alert 与可读名称(含超限数值 200,000)", "interpretation": "R4 修复后的 a11y 附接证据,印证 L6.2 pass(ADR-0016 附接到既有 user-risk 判据)", "scope": "单次运行, viewport 1280x800, 数据集 week-2026-32", "population": null, "ethics": null}
6
6
  {"criterion": "L6.2", "capture": {"type": "screenshot", "provider": "manual", "state": "cap-blocked-r2", "actions": ["select over-cap range", "open export dialog (post R4 fix)"], "schemaVersion": 1, "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}}, "artifact": "L6.2-cap-error-r2.png", "observed_state": "cap-blocked-r2", "result": "captured", "ts": "2026-08-14T11:05:00Z", "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}, "method": "runtime-observation", "observation": "提示含「超出 200,000 行上限」与「按周导出」收窄建议(重评 r2)", "interpretation": "满足 c2 的 Then 子句(剩余量与收窄建议);依赖 assumed 字段 export.row_cap 成立", "scope": "单次运行, viewport 1280x800, 数据集 week-2026-32", "population": null, "ethics": null}
7
7
  {"criterion": "L6.3", "capture": {"type": "interaction trace", "provider": "manual", "state": "return-visible", "actions": ["start export", "navigate away", "navigate back", "read progress and result"], "schemaVersion": 1, "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}}, "artifact": "L6.3-return-trace.json", "observed_state": "return-visible", "result": "captured", "ts": "2026-08-14T11:10:00Z", "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}, "method": "runtime-observation", "observation": "导出中离开 main-list 后返回,exporting 进度态可见,完成后结果可获知(R5 修订 capture plan 后重采)", "interpretation": "满足 c3 的 Then 子句(进度与结果仍可获知);首轮跨导航 session 丢失记 blocked 已按 invalidated 块登记", "scope": "单次运行, viewport 1280x800, 数据集 week-2026-32", "population": null, "ethics": null}
8
- {"criterion": "L6.1", "capture": {"type": "session notes", "provider": "manual", "state": "usertest-round1", "actions": ["facilitate weekly-report export task", "record participant behavior"], "schemaVersion": 1, "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}}, "artifact": "L6.1-usertest-notes.md", "observed_state": "usertest-round1", "result": "captured", "ts": "2026-08-14T10:50:00Z", "request": {"schemaVersion": 1, "viewport": {"width": 1280, "height": 800, "devicePixelRatio": 1.0, "colorScheme": "light"}, "freeze": {"enabled": true, "waitFonts": true, "networkIdle": false}}, "method": "user-test", "observation": "3 名运营参与者完成周报导出任务;其中 2 人先在全局工具栏寻找导出入口,经提示后使用行内入口", "interpretation": null, "scope": "单次会话, 3 名运营参与者, 便利抽样", "population": "3 名运营角色参与者(周报任务,便利抽样)", "ethics": null}
@@ -67,7 +67,7 @@ face: subjective
67
67
  basis: agent-judgment
68
68
  confidence: low
69
69
  disposition: advisory
70
- evidence: rendered 走查(agent-judgment, method=expert-review);非用户证据——涉人记录见 evidence/L6.1-usertest-notes.md(ethics 缺失,已隔离不可采信)
70
+ evidence: rendered 走查(agent-judgment, method=expert-review);非用户证据——evidence/L6.1-usertest-notes.md 仅为合成 schema 示例,未登记到 Manifest,不能支持判断
71
71
  ```
72
72
 
73
73
  ## Positive findings
@@ -107,7 +107,7 @@ evidence: evidence/L6.1-export-trace.json(跨层:交互轨迹 + 度量计
107
107
  ## Limitations statement
108
108
 
109
109
  - 判断类 advisory:术语适配(task-organization 主观面,agent-judgment 非用户证据,confidence=low,advisory 不阻 verdict)
110
- - 用户代表性:本 run 无可用 user-test 证据(L6.1-usertest-notes.md ethics 缺失被隔离为 blocked 证据),全部结论不构成任何「用户会」断言
110
+ - 用户代表性:本 run 无真实 user-test evidence;合成 notes 未登记到 Manifest,全部结论不构成任何「用户会」断言
111
111
  - pass 范围:L6.1/L6.2/L6.3 pass 限单 viewport 1280x800 / 单数据集 week-2026-32 / 单次运行
112
112
  - assumed 依赖:L6.2 pass 依赖 export.row_cap 假设成立
113
113
  - 机器面证明声明与事实一致,不证明体验良好
@@ -8,8 +8,8 @@
8
8
  {"event": "assumption_staged", "field": "export.row_cap", "tier": "T1", "reason": "规模上限未答(队列满,转显式风险确认)", "risk": "上限过低阻断真实导出或过高拖垮同步窗口", "fallback": "50000 行", "ts": "2026-08-14T09:34:00Z"}
9
9
  {"event": "assumption_staged", "field": "export.sync_window", "tier": "T2", "reason": "同步导出限时未逐字确认", "risk": "超时体验未定义", "fallback": "60 秒", "ts": "2026-08-14T09:34:00Z"}
10
10
  {"event": "assumption_staged", "field": "export.column_scope", "tier": "T4", "reason": "文件命名与列范围为局部实现选择", "risk": "隐藏列误出", "fallback": "当前视图列(不含隐藏列)", "ts": "2026-08-14T09:34:00Z"}
11
- {"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": ["l1.goal", "l1.target_user", "l6.c1-c3 表述", "l1.non_goals"], "ts": "2026-08-14T09:40:00Z"}
12
- {"event": "confirm_presented", "batch": "CP-B", "kind": "structural", "items": ["入口 IA 两案:A 全局工具栏 / B 主列表行内批量"], "ts": "2026-08-14T09:40:00Z"}
11
+ {"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": ["l1.goal", "l1.target_user", "l6.c1", "l6.c2", "l6.c3", "l1.non_goals"], "ts": "2026-08-14T09:40:00Z"}
12
+ {"event": "confirm_presented", "batch": "CP-B", "kind": "structural", "items": [{"field": "l2.entry_choice", "value": "入口 IA 两案:A 全局工具栏 / B 主列表行内批量"}], "ts": "2026-08-14T09:40:00Z"}
13
13
  {"event": "confirm_presented", "batch": "CP-C", "kind": "assumption", "items": ["export.row_cap", "export.sync_window", "export.column_scope", "l1.scenes"], "ts": "2026-08-14T09:40:00Z"}
14
14
  {"event": "item_confirmed", "batch": "CP-A", "field": "l1.goal", "ts": "2026-08-14T09:52:00Z"}
15
15
  {"event": "item_confirmed", "batch": "CP-A", "field": "l1.target_user", "ts": "2026-08-14T09:52:00Z"}
@@ -1,6 +1,6 @@
1
1
  {"event": "asked", "question_id": "Q1", "batch": 1, "tier": "T1", "text": "「只导出选中列」的完成判据如何表述?", "impact": "l6.c4", "ts": "2026-08-14T12:41:00Z"}
2
2
  {"event": "answered", "question_id": "Q1", "answer": "Given 运营在主列表圈选 2 列 When 触发行内导出 Then CSV 仅含圈选列且顺序与列表一致(证据:交互记录)", "ts": "2026-08-14T12:42:00Z"}
3
- {"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": ["l6.c4 表述"], "ts": "2026-08-14T12:43:00Z"}
3
+ {"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": ["l6.c4"], "ts": "2026-08-14T12:43:00Z"}
4
4
  {"event": "item_confirmed", "batch": "CP-A", "field": "l6.c4", "ts": "2026-08-14T12:44:00Z"}
5
5
  {"event": "projected", "ts": "2026-08-14T12:45:00Z", "order": ["promote_fields", "append_decision", "apply_decisions", "spec", "bind_first"], "decisions": ["D-0101"], "assumed": [], "mappings": [{"decision": "D-0101", "field": "l6.c4", "spec_section": "L6"}]}
6
6
  {"event": "archived", "ts": "2026-08-14T12:46:00Z"}
@@ -2,7 +2,7 @@
2
2
  {"event": "answered", "question_id": "Q1", "answer": "任务全程可见、跨页保持、结果可获知", "ts": "2026-08-14T10:44:00Z"}
3
3
  {"event": "asked", "question_id": "Q9", "batch": 2, "tier": "T3", "text": "导出进行中的状态呈现——视觉方向(构成级)?", "impact": "D3 decision-report(成形只登记路由,D3 裁决)", "ts": "2026-08-14T10:46:00Z"}
4
4
  {"event": "assumption_staged", "field": "export.task_persist_ttl", "tier": "T2", "reason": "状态条目保留时长未答", "risk": "条目堆积或过早消失", "fallback": "run 内持久(≤10 条)", "ts": "2026-08-14T10:47:00Z"}
5
- {"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": ["l1.goal 修订(supersedes D-0001)", "l6.c4-c6 表述"], "ts": "2026-08-14T10:50:00Z"}
5
+ {"event": "confirm_presented", "batch": "CP-A", "kind": "intent", "items": [{"field": "l1.goal", "value": "修订(supersedes D-0001)"}, "l6.c4", "l6.c5", "l6.c6"], "ts": "2026-08-14T10:50:00Z"}
6
6
  {"event": "confirm_presented", "batch": "CP-C", "kind": "assumption", "items": ["export.task_persist_ttl"], "ts": "2026-08-14T10:50:00Z"}
7
7
  {"event": "item_confirmed", "batch": "CP-A", "field": "l1.goal", "ts": "2026-08-14T10:55:00Z"}
8
8
  {"event": "item_confirmed", "batch": "CP-A", "field": "l6.c4", "ts": "2026-08-14T10:55:00Z"}
@@ -256,13 +256,12 @@ def _action_wait_for_state(page: Any, action: dict, index: int, do: str) -> None
256
256
  if not isinstance(state, str) or not state:
257
257
  raise ValueError(f"actions[{index}].state required for wait_for_state")
258
258
  selector = action.get("selector")
259
- if isinstance(selector, str) and selector:
260
- page.wait_for_selector(selector, timeout=10_000)
261
- else:
262
- page.wait_for_selector(
263
- f'[data-state="{state}"]',
264
- timeout=10_000,
265
- )
259
+ target = (
260
+ f'{selector}[data-state="{state}"]'
261
+ if isinstance(selector, str) and selector
262
+ else f'[data-state="{state}"]'
263
+ )
264
+ page.wait_for_selector(target, timeout=10_000)
266
265
 
267
266
 
268
267
  def _action_wait(page: Any, action: dict, index: int, do: str) -> None:
@@ -121,9 +121,37 @@ def _structured(call_response: dict) -> dict:
121
121
  return json.loads(text)
122
122
 
123
123
 
124
+ class _RecordingPage:
125
+ def __init__(self) -> None:
126
+ self.waits: list[tuple[str, int]] = []
127
+
128
+ def wait_for_selector(self, selector: str, *, timeout: int) -> None:
129
+ self.waits.append((selector, timeout))
130
+
131
+
124
132
  class EvidencePurePathTests(unittest.TestCase):
125
133
  """No-chromium transport and direct runtime-interface tests."""
126
134
 
135
+ def test_wait_for_state_combines_selector_and_state(self) -> None:
136
+ page = _RecordingPage()
137
+ capture_runtime._action_wait_for_state(
138
+ page,
139
+ {"do": "wait_for_state", "selector": "body", "state": "ready"},
140
+ 0,
141
+ "wait_for_state",
142
+ )
143
+ self.assertEqual(page.waits, [('body[data-state="ready"]', 10_000)])
144
+
145
+ def test_wait_for_state_without_selector_targets_any_matching_state(self) -> None:
146
+ page = _RecordingPage()
147
+ capture_runtime._action_wait_for_state(
148
+ page,
149
+ {"do": "wait_for_state", "state": "ready"},
150
+ 0,
151
+ "wait_for_state",
152
+ )
153
+ self.assertEqual(page.waits, [('[data-state="ready"]', 10_000)])
154
+
127
155
  def test_parse_capture_contract_requires_schema_and_viewport(self) -> None:
128
156
  from design_playbook.mcp.evidence import capture_contract
129
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "design-playbook",
3
- "version": "0.20.0",
3
+ "version": "0.20.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",
@@ -43,7 +43,20 @@ stay protocol-side. Gate policy lives in ``g10_design_decisions.py``.
43
43
 
44
44
  Flow-map values inside ``- {k: v, ...}`` items may not contain ASCII commas
45
45
  or braces (use full-width punctuation in prose values); this is the declared
46
- shape, same contract style as the seven-column audit rows.
46
+ shape, same contract style as the seven-column audit rows. Items may fold
47
+ across lines (issue #44): a ``- {`` item that does not close its brace on
48
+ the marker line continues on the following lines until the braces balance —
49
+ single-line items parse exactly as before. Folds break at commas (the
50
+ schema example's shape): a break that does not end the accumulated text
51
+ with a comma (or the opening brace) would merge the next key into the
52
+ previous value, so the join records a :class:`FoldIssue` and G10 reports it
53
+ (:code:`G10.fold_break_not_comma`); a fold that never balances by the end
54
+ of the block records :code:`G10.fold_unterminated` (fail-closed, with the
55
+ remaining shape errors still firing).
56
+
57
+ ``dd:`` is the R3 challenge channel, never an observation link: values
58
+ carried by positive (S0) findings are excluded from the challenge face and
59
+ reported as structural errors by G10 (issue #44).
47
60
  """
48
61
  from __future__ import annotations
49
62
 
@@ -51,6 +64,8 @@ import re
51
64
  from dataclasses import dataclass, field
52
65
  from typing import Any
53
66
 
67
+ from design_playbook.scripts.g2_g4_pointback import _findings
68
+
54
69
  # --- closed enums (design-prototype 4.1 machine face) -----------------------
55
70
 
56
71
  DD_TIERS = frozenset({"record", "compare", "explore"})
@@ -120,6 +135,7 @@ class DDEntry:
120
135
  supersedes: str = ""
121
136
  stale: str = ""
122
137
  stale_review: dict[str, str] = field(default_factory=dict)
138
+ fold_issues: tuple["FoldIssue", ...] = ()
123
139
  block: str = ""
124
140
 
125
141
  # -- convenience accessors ------------------------------------------
@@ -168,6 +184,23 @@ class DDEntry:
168
184
  return [item.get("id", "") for item in self.candidates]
169
185
 
170
186
 
187
+ @dataclass(frozen=True)
188
+ class FoldIssue:
189
+ """A fold defect found while joining folded flow-map items.
190
+
191
+ ``kind`` is ``"break_not_comma"`` (the accumulated fold text did not end
192
+ with a comma — or the opening brace — when a continuation was appended,
193
+ so the next key merges into the previous value) or ``"unterminated"``
194
+ (the braces never balanced before the block ended). ``line`` is the
195
+ 1-based line of the fold-opening marker inside the entry block;
196
+ ``tail`` carries the offending text tail for the error face.
197
+ """
198
+
199
+ kind: str
200
+ line: int
201
+ tail: str = ""
202
+
203
+
171
204
  @dataclass(frozen=True)
172
205
  class ESignals:
173
206
  """Machine-judgeable E-criterion signals gathered from run artifacts."""
@@ -208,6 +241,55 @@ def _flow_map(item: str) -> dict[str, str]:
208
241
  return {"value": _scalar(item)}
209
242
 
210
243
 
244
+ def _join_folded_flow_maps(
245
+ lines: list[str]) -> tuple[list[str], tuple[FoldIssue, ...]]:
246
+ """Join folded ``- {...}`` flow-map items onto their marker line.
247
+
248
+ Issue #44: an item that opens a ``{`` without closing it on the marker
249
+ line continues on the following lines (the fold the entry schema
250
+ example already shows) until its braces balance; continuation text is
251
+ appended to the marker line so the rest of the parser sees one logical
252
+ line. Values never contain ASCII commas or braces (declared shape), so
253
+ brace balance is an unambiguous fold terminator. Folds break at commas:
254
+ a continuation appended to accumulated text that does not end with a
255
+ comma (or the opening brace) would silently merge the next key into the
256
+ previous value, so the break is recorded as a :class:`FoldIssue`
257
+ (fail-closed; G10 reports it). An unterminated fold stays joined and
258
+ records its own issue plus the downstream shape checks (fail-closed).
259
+ Returns ``(joined lines, fold issues)``.
260
+ """
261
+ out: list[str] = []
262
+ issues: list[FoldIssue] = []
263
+ fold: str | None = None
264
+ fold_line = 0
265
+ for lineno, raw_line in enumerate(lines, 1):
266
+ stripped = raw_line.strip()
267
+ if fold is not None:
268
+ if not fold.rstrip().endswith((",", "{")):
269
+ issues.append(FoldIssue(
270
+ kind="break_not_comma",
271
+ line=fold_line,
272
+ tail=fold.strip()[-60:],
273
+ ))
274
+ fold = fold.rstrip() + " " + stripped
275
+ if fold.count("{") <= fold.count("}"):
276
+ out.append(fold)
277
+ fold = None
278
+ continue
279
+ if (
280
+ stripped.startswith("- ")
281
+ and stripped.count("{") > stripped.count("}")
282
+ ):
283
+ fold = raw_line.rstrip()
284
+ fold_line = lineno
285
+ continue
286
+ out.append(raw_line)
287
+ if fold is not None:
288
+ issues.append(FoldIssue(kind="unterminated", line=fold_line))
289
+ out.append(fold)
290
+ return out, tuple(issues)
291
+
292
+
211
293
  def _entry_blocks(text: str) -> list[tuple[str, str]]:
212
294
  """Split the report into (id, body) blocks by DD entry heading."""
213
295
  matches = list(DD_HEADING.finditer(text))
@@ -231,7 +313,8 @@ def _parse_entry(entry_id: str, body: str) -> DDEntry:
231
313
  current_section: str | None = None
232
314
  current_list_key: str | None = None
233
315
 
234
- for raw_line in block.splitlines():
316
+ joined, fold_issues = _join_folded_flow_maps(block.splitlines())
317
+ for raw_line in joined:
235
318
  stripped = raw_line.strip()
236
319
  if not stripped or stripped.startswith("```"):
237
320
  continue
@@ -307,6 +390,7 @@ def _parse_entry(entry_id: str, body: str) -> DDEntry:
307
390
  supersedes=fields.get("supersedes", ""),
308
391
  stale=fields.get("stale", ""),
309
392
  stale_review=_scalars("stale_review"),
393
+ fold_issues=fold_issues,
310
394
  block=block,
311
395
  )
312
396
 
@@ -319,20 +403,74 @@ def parse_dd_entries(text: str) -> list[DDEntry]:
319
403
  ]
320
404
 
321
405
 
406
+ # ``none`` value token for the verbatim top block: a trailing same-line
407
+ # note after the token is tolerated commentary, never a declared change
408
+ # (issue #44). The token must end at whitespace or punctuation so values
409
+ # like ``nonempty`` and ``none_x`` stay fail-closed non-none (underscore
410
+ # joins the lookalike continuation class with ``-``, e.g. ``none-such``).
411
+ NONE_VALUE = re.compile(r"none(?=$|[^0-9A-Za-z_-])", re.I)
412
+
413
+
322
414
  def top_block_baseline_change(text: str) -> bool:
323
- """True when the verbatim top block declares ``baseline-changes != none``."""
415
+ """True when the verbatim top block declares ``baseline-changes != none``.
416
+
417
+ ``none`` may carry a trailing same-line note (anything after the value
418
+ token); notes never turn ``none`` into a declared change. Substantive
419
+ commentary belongs on its own line.
420
+ """
324
421
  match = re.search(r"^baseline-changes:[ \t]*(\S.*)$", text, re.M)
325
422
  if match is None:
326
423
  return False
327
- return match.group(1).strip().casefold() != "none"
424
+ return NONE_VALUE.match(match.group(1).strip()) is None
425
+
426
+
427
+ def is_positive_finding(parsed: dict[str, list[str]]) -> bool:
428
+ """True when a parsed point-back finding sits on the S0 (info) axis."""
429
+ values = parsed.get("severity") or [""]
430
+ return values[0].strip().casefold() == "s0"
431
+
432
+
433
+ def positive_dd_refs(
434
+ text: str) -> tuple[tuple[int, tuple[str, ...]], ...]:
435
+ """``(finding index, dd refs)`` for every positive finding carrying ``dd:``.
436
+
437
+ Issue #44: ``dd:`` is the R3 challenge channel and never rides a
438
+ positive observation. These are structural errors G10 reports
439
+ (fail-closed) instead of silently reading them as challenges.
440
+ """
441
+ out: list[tuple[int, tuple[str, ...]]] = []
442
+ for index, parsed in enumerate(_findings(text), 1):
443
+ refs = tuple(
444
+ value.strip().rstrip(",") for value in parsed.get("dd", [])
445
+ if value.strip())
446
+ if refs and is_positive_finding(parsed):
447
+ out.append((index, refs))
448
+ return tuple(out)
449
+
450
+
451
+ def _positive_dd_block(block: str) -> bool:
452
+ return any(
453
+ parsed.get("dd") and is_positive_finding(parsed)
454
+ for parsed in _findings(block)
455
+ )
328
456
 
329
457
 
330
458
  def dd_refs_in_pointback(text: str) -> tuple[str, ...]:
331
- """Collect ``dd:`` targets from finding field lines / invalidated rows."""
459
+ """Collect ``dd:`` challenge targets from finding field lines.
460
+
461
+ Issue #44: ``dd:`` values carried by positive (S0) findings record
462
+ observation links, not challenges — their paragraphs are skipped so a
463
+ positive observation can never fire a false re-entry / E3 signal.
464
+ Paragraphs that are not findings (e.g. a bare ``dd:`` line) keep the
465
+ legacy raw face.
466
+ """
332
467
  targets: list[str] = []
333
- for match in re.finditer(r"^dd:[ \t]*(\S+)", text, re.I | re.M):
334
- ref = match.group(1).strip().rstrip(",")
335
- targets.append(ref)
468
+ for block in re.split(r"\n\s*\n", text):
469
+ if _positive_dd_block(block):
470
+ continue
471
+ for match in re.finditer(r"^dd:[ \t]*(\S+)", block, re.I | re.M):
472
+ ref = match.group(1).strip().rstrip(",")
473
+ targets.append(ref)
336
474
  return tuple(targets)
337
475
 
338
476
 
@@ -41,6 +41,7 @@ import re
41
41
  from dataclasses import dataclass
42
42
 
43
43
  from design_playbook.scripts._diagnostics import Finding, finding
44
+ from design_playbook.scripts.dd_entries import is_positive_finding
44
45
  from design_playbook.scripts.g2_g4_pointback import _findings
45
46
  from design_playbook.scripts.repair_rounds import is_blocking
46
47
 
@@ -167,9 +168,16 @@ def check_routes(text: str) -> list[Finding]:
167
168
 
168
169
 
169
170
  def dd_targets(text: str) -> tuple[str, ...]:
170
- """dd: references carried by findings (R3 challenge face)."""
171
+ """dd: references carried by findings (R3 challenge face).
172
+
173
+ Issue #44: positive (S0) findings carry observation links, not
174
+ challenges — their ``dd:`` values never fire E3 (G10 reports the
175
+ misuse as a structural error instead).
176
+ """
171
177
  targets: list[str] = []
172
178
  for parsed in _findings(text):
179
+ if is_positive_finding(parsed):
180
+ continue
173
181
  targets.extend(value.strip() for value in parsed.get("dd", [])
174
182
  if value.strip())
175
183
  return tuple(targets)
@@ -6,8 +6,9 @@ declared by the protocol — entry completeness, tier/status enums, tier
6
6
  recording obligations (R one-line rationale / C trade-off record / E user
7
7
  confirmation), supersedes existence + acyclicity, registry rule-reference
8
8
  cross-check, preview transaction linkage (decision_id), R3 re-entry
9
- resolution (dd: challenges must end invalidated with an E-tier revision),
10
- and the baseline-drift stale review (three exits: keep / revise / escalate).
9
+ resolution (dd: challenges must end invalidated with an E-tier revision;
10
+ ``dd:`` on a positive finding is a shape error, issue #44), and the
11
+ baseline-drift stale review (three exits: keep / revise / escalate).
11
12
 
12
13
  Comparison-matrix quality, trade-off sufficiency, and tier-grading
13
14
  judgement calls (composition change, identity drift beyond declared
@@ -35,6 +36,7 @@ from design_playbook.scripts.dd_entries import (
35
36
  is_cross_run_ref,
36
37
  local_dd_id,
37
38
  parse_dd_entries,
39
+ positive_dd_refs,
38
40
  )
39
41
 
40
42
  # rules.md ships inside the package (read-only protocol consumption, the
@@ -102,6 +104,11 @@ def _entry_checks(entries: list[DDEntry]) -> list[Finding]:
102
104
  ))
103
105
  seen[label] = index
104
106
 
107
+ # fold defects first (issue #44 follow-up): a named unterminated /
108
+ # comma-less fold error outranks the indirect missing_* findings it
109
+ # causes downstream, so the error face points at the real defect.
110
+ errs += _fold_checks(entry)
111
+
105
112
  for key in ("id", "tier", "question", "status"):
106
113
  if not entry.fields.get(key, "").strip():
107
114
  errs.append(finding(
@@ -143,6 +150,45 @@ def _entry_checks(entries: list[DDEntry]) -> list[Finding]:
143
150
  return errs
144
151
 
145
152
 
153
+ def _fold_checks(entry: DDEntry) -> list[Finding]:
154
+ """Fold defects on ``- {…}`` items (issue #44 follow-up, fail-closed).
155
+
156
+ A fold break without a comma merges the next key into the previous
157
+ value (the parse alone would accept it silently); a fold that never
158
+ balances swallows the rest of the block and only indirect missing_*
159
+ errors would fire. Both get a named error up front, with the block line
160
+ of the fold-opening marker; the remaining shape errors still fire.
161
+ """
162
+ errs: list[Finding] = []
163
+ for issue in entry.fold_issues:
164
+ if issue.kind == "unterminated":
165
+ errs.append(finding(
166
+ "G10.fold_unterminated",
167
+ f"G10 decisions: {entry.id} opens a folded flow-map item at "
168
+ f"block line {issue.line} that never closes its brace — the "
169
+ "fold swallowed the rest of the entry block",
170
+ owner=_fmt(entry.id),
171
+ expected="braces balance inside the entry block",
172
+ actual=f"unterminated fold from block line {issue.line}",
173
+ repair="Close the brace, or unfold to the canonical "
174
+ "single-line item",
175
+ ))
176
+ else:
177
+ errs.append(finding(
178
+ "G10.fold_break_not_comma",
179
+ f"G10 decisions: {entry.id} folds a flow-map item at block "
180
+ f"line {issue.line} without a comma at the break — the next "
181
+ "key merges into the previous value",
182
+ owner=_fmt(entry.id),
183
+ expected="fold breaks end with a comma (or the opening "
184
+ "brace)",
185
+ actual=f"break after {issue.tail!r}",
186
+ repair="End the folded line with a comma before continuing "
187
+ "the item on the next line",
188
+ ))
189
+ return errs
190
+
191
+
146
192
  def _candidate_checks(entry: DDEntry) -> list[Finding]:
147
193
  errs: list[Finding] = []
148
194
  label = entry.id
@@ -669,6 +715,31 @@ def _reentry_checks(
669
715
  return errs
670
716
 
671
717
 
718
+ def _positive_dd_checks(pointback_text: str | None) -> list[Finding]:
719
+ """``dd:`` on a positive (S0) finding is a shape error (issue #44).
720
+
721
+ ``dd:`` is the R3 challenge channel; riding it on a positive
722
+ observation reads as a challenge downstream. Fail closed with a
723
+ precise error instead of silently ignoring the line.
724
+ """
725
+ if not pointback_text:
726
+ return []
727
+ errs: list[Finding] = []
728
+ for index, refs in positive_dd_refs(pointback_text):
729
+ errs.append(finding(
730
+ "G10.dd_on_positive_finding",
731
+ f"G10 decisions: positive finding {index} carries "
732
+ f"dd: {', '.join(refs)} — dd: is the R3 challenge channel and "
733
+ "never rides a positive observation",
734
+ owner=f"point-back.md#finding.{index}",
735
+ expected="dd: only on non-positive (S1-S3) findings",
736
+ actual="dd: on severity S0",
737
+ repair="Drop the dd: line and record the observation link as "
738
+ "prose (e.g. an evidence note line)",
739
+ ))
740
+ return errs
741
+
742
+
672
743
  def _preview_link_checks(
673
744
  entries: list[DDEntry],
674
745
  preview_dir: Path | None) -> list[Finding]:
@@ -929,6 +1000,7 @@ def check_g10(
929
1000
  report_text=report_text,
930
1001
  )
931
1002
  errs += _reentry_checks(entries, signals.dd_targets)
1003
+ errs += _positive_dd_checks(pointback_text)
932
1004
  errs += _preview_link_checks(entries, preview_dir)
933
1005
  errs += _stale_checks(entries, baseline_state)
934
1006
  errs += _signal_checks(entries, signals, run_profile_tier)
@@ -37,7 +37,10 @@ if str(_PKG_ROOT) not in sys.path:
37
37
  from design_playbook.scripts import rules_registry # noqa: E402
38
38
  from design_playbook.scripts._diagnostics import Finding, finding # noqa: E402
39
39
  from design_playbook.scripts.rules_registry import RegistryError # noqa: E402
40
- from design_playbook.scripts.run_profile import parse_run_profile # noqa: E402
40
+ from design_playbook.scripts.run_profile import ( # noqa: E402
41
+ parse_run_profile,
42
+ validate_run_profile,
43
+ )
41
44
 
42
45
  REGISTRY_PATH = (
43
46
  Path(__file__).resolve().parents[1] / "skills" / "design-playbook"
@@ -144,9 +147,16 @@ def main(argv: list[str]) -> int:
144
147
  try:
145
148
  profile = parse_run_profile(
146
149
  plan_path.read_text(encoding="utf-8"))
147
- tier = profile.tier if profile is not None else None
148
- except (OSError, UnicodeError):
149
- tier = None
150
+ except (OSError, UnicodeError) as exc:
151
+ print(f"G8 INVALID: cannot read {plan_path}: {exc}", file=sys.stderr)
152
+ return 2
153
+ profile_errors = validate_run_profile(profile) if profile is not None else []
154
+ if profile_errors:
155
+ print("G8 INVALID:")
156
+ for error in profile_errors:
157
+ print(f" FAIL run-profile invalid: {error}")
158
+ return 1
159
+ tier = profile.tier if profile is not None else None
150
160
  findings = check_g8_run(craft_text, entries, tier)
151
161
  if not findings:
152
162
  print("G8 OK: craft audit rows satisfy the run-level registry gate")
@@ -36,9 +36,9 @@ from design_playbook.scripts.g2_g4_pointback import FIELD_LINE
36
36
  MIN_DISTINCT_RUNS = 3
37
37
  MIN_DISTINCT_CONTEXTS = 2
38
38
  MAX_UNEXPLAINED_FALSE_POSITIVES = 0
39
- # Positive observations (S0) are not defect signals the derivation
40
- # never lets them enter the candidate queue.
41
- EXCLUDED_SEVERITIES = frozenset({"S0"})
39
+ # Candidate derivation fails closed: only defect severities enter history.
40
+ # S0 is a positive observation; blank, legacy, and unknown values are invalid.
41
+ CANDIDATE_SEVERITIES = frozenset({"S3", "S2", "S1"})
42
42
 
43
43
  UNSPECIFIED_CONTEXT = "(unspecified)"
44
44
 
@@ -134,8 +134,8 @@ def derive_candidates(
134
134
  """
135
135
  groups: dict[str, list[Occurrence]] = {}
136
136
  for occurrence in occurrences:
137
- if occurrence.severity.strip() in EXCLUDED_SEVERITIES:
138
- continue # positive observations are not defect signals
137
+ if occurrence.severity.strip() not in CANDIDATE_SEVERITIES:
138
+ continue
139
139
  key = normalize(occurrence.issue)
140
140
  if key:
141
141
  groups.setdefault(key, []).append(occurrence)