design-playbook 0.20.1 → 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.
@@ -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
  - 机器面证明声明与事实一致,不证明体验良好
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "design-playbook",
3
- "version": "0.20.1",
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",
@@ -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")
@@ -13,6 +13,12 @@ from typing import Any
13
13
 
14
14
  from design_playbook.mcp.evidence.ledger_syntax import LedgerFacts, parse_ledger
15
15
  from design_playbook.mcp.preview.integrity import PreviewSnapshot, inspect_preview
16
+ from design_playbook.scripts.dd_entries import DDEntry, parse_dd_entries
17
+ from design_playbook.scripts.run_profile import RunProfile, parse_run_profile
18
+ from design_playbook.scripts.shaping_log import (
19
+ ShapingLogError,
20
+ parse_shaping_log,
21
+ )
16
22
  from design_playbook.scripts.verdict_syntax import VerdictFacts, parse_verdict
17
23
 
18
24
 
@@ -38,6 +44,10 @@ class RunFacts:
38
44
  evidence_dir: Path | None
39
45
  spec_text: str
40
46
  pointback_text: str
47
+ plan_text: str
48
+ plan_fill_artifacts: tuple[str, ...]
49
+ craft_guard_exists: bool
50
+ craft_guard_text: str
41
51
  ledger: LedgerFacts
42
52
  verdict: VerdictFacts
43
53
  preview: PreviewSnapshot | None
@@ -46,6 +56,11 @@ class RunFacts:
46
56
  _baseline_text: str | None
47
57
  baseline_state_error: str | None
48
58
  read_errors: tuple[ArtifactReadFact, ...]
59
+ run_profile: RunProfile | None = None
60
+ decision_report_text: str = ""
61
+ decision_entries: tuple[DDEntry, ...] = ()
62
+ shaping_events: tuple[dict[str, Any], ...] | None = None
63
+ shaping_error: str | None = None
49
64
 
50
65
  @property
51
66
  def manifest_entries(self) -> tuple[dict[str, Any], ...]:
@@ -60,6 +75,19 @@ class RunFacts:
60
75
  return json.loads(self._baseline_text)
61
76
 
62
77
 
78
+ @dataclass(frozen=True)
79
+ class _OptionalRunFacts:
80
+ plan_text: str = ""
81
+ run_profile: RunProfile | None = None
82
+ decision_report_text: str = ""
83
+ decision_entries: tuple[DDEntry, ...] = ()
84
+ shaping_events: tuple[dict[str, Any], ...] | None = None
85
+ shaping_error: str | None = None
86
+ craft_guard_exists: bool = False
87
+ craft_guard_text: str = ""
88
+ read_errors: tuple[ArtifactReadFact, ...] = ()
89
+
90
+
63
91
  def _read_manifest(
64
92
  evidence_dir: Path | None,
65
93
  ) -> tuple[tuple[str, ...], tuple[ArtifactReadFact, ...]]:
@@ -155,6 +183,78 @@ def _existing_paths(run_root: Path | None) -> frozenset[str]:
155
183
  return frozenset(paths)
156
184
 
157
185
 
186
+ def _plan_fill_artifacts(
187
+ run_root: Path | None,
188
+ plan_text: str,
189
+ ) -> tuple[str, ...]:
190
+ """Capture existing fill declarations while the run snapshot is loaded."""
191
+ if run_root is None:
192
+ return ()
193
+ found: list[str] = []
194
+ fenced = False
195
+ for line in plan_text.splitlines():
196
+ if line.lstrip().startswith("```"):
197
+ fenced = not fenced
198
+ continue
199
+ if fenced or not line.startswith("fill:"):
200
+ continue
201
+ declared = line[5:].strip().split()[0].rstrip(",") if line[5:].strip() else ""
202
+ if not declared:
203
+ continue
204
+ candidate = Path(declared)
205
+ bases = (
206
+ [candidate] if candidate.is_absolute()
207
+ else [run_root / candidate, Path.cwd() / candidate]
208
+ )
209
+ if any(base.is_file() for base in bases) and declared not in found:
210
+ found.append(declared)
211
+ return tuple(found)
212
+
213
+
214
+ def _read_optional_run_facts(run_root: Path | None) -> _OptionalRunFacts:
215
+ """Load optional vNext artifacts into one immutable snapshot."""
216
+ if run_root is None:
217
+ return _OptionalRunFacts()
218
+
219
+ plan_text, plan_error = _read_text("plan", run_root / "plan.md")
220
+ profile = parse_run_profile(plan_text) if plan_text else None
221
+
222
+ entries: tuple[DDEntry, ...] = ()
223
+ report_text = ""
224
+ report = run_root / "decision-report.md"
225
+ report_text, report_error = _read_text("decision_report", report)
226
+ if report_text:
227
+ entries = tuple(parse_dd_entries(report_text))
228
+
229
+ shaping_events: tuple[dict[str, Any], ...] | None = None
230
+ shaping_error: str | None = None
231
+ shaping_log = run_root / "shaping" / "shaping-log.jsonl"
232
+ try:
233
+ if shaping_log.is_file():
234
+ shaping_events = tuple(
235
+ parse_shaping_log(shaping_log.read_text(encoding="utf-8"))
236
+ )
237
+ except (OSError, UnicodeError, ShapingLogError) as exc:
238
+ shaping_events = None
239
+ shaping_error = str(exc)
240
+ craft_guard_text, craft_error = _read_text(
241
+ "craft_guard", run_root / "craft-guard.md"
242
+ )
243
+ craft_guard_exists = craft_error is None or craft_error.code != "missing"
244
+ errors = tuple(error for error in (plan_error, report_error, craft_error) if error)
245
+ return _OptionalRunFacts(
246
+ plan_text=plan_text,
247
+ run_profile=profile,
248
+ decision_report_text=report_text,
249
+ decision_entries=entries,
250
+ shaping_events=shaping_events,
251
+ shaping_error=shaping_error,
252
+ craft_guard_exists=craft_guard_exists,
253
+ craft_guard_text=craft_guard_text,
254
+ read_errors=errors,
255
+ )
256
+
257
+
158
258
  def capture_run_facts(
159
259
  *,
160
260
  spec_path: Path | None = None,
@@ -189,6 +289,7 @@ def capture_run_facts(
189
289
  baseline_text, baseline_error = _read_baseline(run_root)
190
290
  manifest_lines, manifest_errors = _read_manifest(evidence_dir)
191
291
  preview = inspect_preview(preview_dir) if preview_dir is not None else None
292
+ optional = _read_optional_run_facts(run_root)
192
293
  return RunFacts(
193
294
  run_root=run_root,
194
295
  spec_path=spec_path,
@@ -197,6 +298,10 @@ def capture_run_facts(
197
298
  evidence_dir=evidence_dir,
198
299
  spec_text=spec_text,
199
300
  pointback_text=pointback_text,
301
+ plan_text=optional.plan_text,
302
+ plan_fill_artifacts=_plan_fill_artifacts(run_root, optional.plan_text),
303
+ craft_guard_exists=optional.craft_guard_exists,
304
+ craft_guard_text=optional.craft_guard_text,
200
305
  ledger=parse_ledger(pointback_text),
201
306
  verdict=parse_verdict(pointback_text),
202
307
  preview=preview,
@@ -208,5 +313,12 @@ def capture_run_facts(
208
313
  error
209
314
  for error in (spec_error, pointback_error)
210
315
  if error is not None
211
- ) + manifest_errors,
316
+ ) + manifest_errors + tuple(
317
+ error for error in optional.read_errors if error.code != "missing"
318
+ ),
319
+ run_profile=optional.run_profile,
320
+ decision_report_text=optional.decision_report_text,
321
+ decision_entries=optional.decision_entries,
322
+ shaping_events=optional.shaping_events,
323
+ shaping_error=optional.shaping_error,
212
324
  )
@@ -11,7 +11,6 @@ from __future__ import annotations
11
11
 
12
12
  import argparse
13
13
  import json
14
- import re
15
14
  import sys
16
15
  from dataclasses import dataclass
17
16
  from pathlib import Path
@@ -43,22 +42,13 @@ else:
43
42
  # its status decision from the shared canonical value.
44
43
  from design_playbook.scripts.stages import STAGES, STAGES_BY_KEY # noqa: E402
45
44
  from design_playbook.scripts.run_facts import RunFacts, capture_run_facts # noqa: E402
46
- from design_playbook.scripts.run_profile import parse_run_profile # noqa: E402
47
- from design_playbook.scripts.shaping_log import ( # noqa: E402
48
- ShapingLogError,
49
- load_shaping_facts,
50
- queue_state,
51
- )
45
+ from design_playbook.scripts.shaping_log import queue_state # noqa: E402
52
46
 
53
47
  # vNext S4 re-entry narration (loop-prototype 7.1): repair rounds, route
54
48
  # hit counts, dd supersedes / stale reviews, derived escalation signals,
55
49
  # and the close_reason terminal narration, all read from artifacts the
56
50
  # gates already consume (additive; plain runs report empty faces).
57
- from design_playbook.scripts.dd_entries import ( # noqa: E402
58
- DD_HEADING,
59
- dd_refs_in_pointback,
60
- parse_dd_entries,
61
- )
51
+ from design_playbook.scripts.dd_entries import dd_refs_in_pointback # noqa: E402
62
52
  from design_playbook.scripts.escalation_signals import ( # noqa: E402
63
53
  EscalationSignal,
64
54
  collect_signals,
@@ -118,25 +108,16 @@ class RepairNarration:
118
108
 
119
109
 
120
110
  def _repair_narration(
121
- pointback_text: str, run_root: Path,
122
- upgrades: tuple[str, ...]) -> RepairNarration:
111
+ pointback_text: str,
112
+ upgrades: tuple[str, ...],
113
+ decision_entries: tuple,
114
+ ) -> RepairNarration:
123
115
  """Derive the S4 re-entry faces from artifacts in the run root."""
124
116
  rounds = parse_round_facts(pointback_text).max_rounds
125
117
  routes = tuple(sorted(route_hits(pointback_text).items()))
126
118
  dd_supersedes = len(dd_refs_in_pointback(pointback_text))
127
- stale_reviews = 0
128
- dd_explore = False
129
- report = run_root / "decision-report.md"
130
- if report.is_file():
131
- try:
132
- text = report.read_text(encoding="utf-8")
133
- except (OSError, UnicodeError):
134
- text = ""
135
- if text and DD_HEADING.search(text):
136
- entries = parse_dd_entries(text)
137
- stale_reviews = sum(
138
- 1 for entry in entries if entry.stale_review)
139
- dd_explore = any(entry.tier == "explore" for entry in entries)
119
+ stale_reviews = sum(1 for entry in decision_entries if entry.stale_review)
120
+ dd_explore = any(entry.tier == "explore" for entry in decision_entries)
140
121
  signals = list(collect_signals(pointback_text, dd_explore=dd_explore))
141
122
  signals.extend(recorded_regrades(upgrades))
142
123
  return RepairNarration(
@@ -149,47 +130,36 @@ def _repair_narration(
149
130
  )
150
131
 
151
132
 
152
- def inspect_vnext(run_root: Path) -> VnextNarration:
153
- """Read run-profile (plan.md) and shaping session state (additive)."""
133
+ def inspect_vnext(
134
+ run_root: Path,
135
+ run_facts: RunFacts | None = None,
136
+ ) -> VnextNarration:
137
+ """Project vNext facts from one immutable run snapshot."""
138
+ facts = run_facts or capture_run_facts(run_root=run_root)
154
139
  tier = None
155
140
  confirmed_by = None
156
141
  skipped: tuple[tuple[str, str], ...] = ()
157
142
  upgrades: tuple[str, ...] = ()
158
- plan_path = run_root / "plan.md"
159
- if plan_path.is_file():
160
- try:
161
- profile = parse_run_profile(
162
- plan_path.read_text(encoding="utf-8"))
163
- except (OSError, UnicodeError):
164
- profile = None
165
- if profile is not None:
166
- tier = profile.tier or None
167
- confirmed_by = profile.confirmed_by or None
168
- skipped = profile.skipped
169
- upgrades = profile.upgrades
143
+ profile = facts.run_profile
144
+ if profile is not None:
145
+ tier = profile.tier or None
146
+ confirmed_by = profile.confirmed_by or None
147
+ skipped = profile.skipped
148
+ upgrades = profile.upgrades
170
149
  shaping: str | None = None
171
- try:
172
- session = load_shaping_facts(run_root)
173
- except (ShapingLogError, OSError, UnicodeError):
150
+ if facts.shaping_error is not None:
174
151
  shaping = "unreadable"
175
- else:
176
- if session is not None:
177
- shaping = queue_state(list(session.events))
178
- pointback = run_root / "point-back.md"
179
- six_block = False
180
- invalidated = False
181
- pb_text = ""
182
- if pointback.is_file():
183
- try:
184
- pb_text = pointback.read_text(encoding="utf-8")
185
- except (OSError, UnicodeError):
186
- pb_text = ""
187
- six_block = "## Coverage statement" in pb_text
188
- invalidated = "\ninvalidated:" in pb_text or pb_text.startswith(
189
- "invalidated:")
152
+ elif facts.shaping_events is not None:
153
+ shaping = queue_state(list(facts.shaping_events))
154
+ pb_text = facts.pointback_text
155
+ six_block = "## Coverage statement" in pb_text
156
+ invalidated = "\ninvalidated:" in pb_text or pb_text.startswith(
157
+ "invalidated:")
190
158
  repair = None
191
159
  if pb_text:
192
- repair = _repair_narration(pb_text, run_root, upgrades)
160
+ repair = _repair_narration(
161
+ pb_text, upgrades, facts.decision_entries
162
+ )
193
163
  return VnextNarration(
194
164
  tier=tier, confirmed_by=confirmed_by, skipped=skipped,
195
165
  upgrades=upgrades, shaping=shaping,
@@ -206,47 +176,7 @@ class StageState:
206
176
  evidence: list[str]
207
177
 
208
178
 
209
- # Issue #44: fill surfaces may land in the host tree (product side) instead
210
- # of the run root. When plan.md registers those paths as ``fill:`` field
211
- # lines, the fill stage is also judged on their existence — one path per
212
- # line, run-root-relative or host-project-relative (the orchestrating cwd).
213
- # Only unfenced column-0 field lines are declarations; fenced blocks are
214
- # prose/examples and are never read as declarations.
215
- PLAN_FILL_LINE = re.compile(r"^fill:[ \t]*(\S+)")
216
-
217
-
218
- def _plan_fill_artifacts(run_root: Path) -> list[str]:
219
- """Declared fill artifact paths from plan.md that exist on disk.
220
-
221
- Fenced code blocks (```` ``` ````) are skipped while scanning: an
222
- example or prose block citing ``fill: spec.md`` is not a declaration
223
- (fail-closed — the fill stage stays unchecked rather than counting a
224
- narrated example).
225
- """
226
- try:
227
- text = (run_root / "plan.md").read_text(encoding="utf-8")
228
- except (OSError, UnicodeError):
229
- return []
230
- found: list[str] = []
231
- fenced = False
232
- for line in text.splitlines():
233
- if line.lstrip().startswith("```"):
234
- fenced = not fenced
235
- continue
236
- if fenced:
237
- continue
238
- match = PLAN_FILL_LINE.match(line)
239
- if match is None:
240
- continue
241
- declared = match.group(1).strip().rstrip(",")
242
- candidate = Path(declared)
243
- bases = (
244
- [candidate] if candidate.is_absolute()
245
- else [run_root / candidate, Path.cwd() / candidate]
246
- )
247
- if any(base.is_file() for base in bases) and declared not in found:
248
- found.append(declared)
249
- return found
179
+ # Plan fill declarations are captured by RunFacts; status only projects them.
250
180
 
251
181
 
252
182
  def inspect_run(
@@ -255,7 +185,7 @@ def inspect_run(
255
185
  ) -> list[StageState]:
256
186
  facts = run_facts or capture_run_facts(run_root=run_root)
257
187
  snapshot = preview_snapshot or facts.preview or inspect_preview(run_root / "preview")
258
- plan_fills = _plan_fill_artifacts(run_root)
188
+ plan_fills = list(facts.plan_fill_artifacts)
259
189
  states: list[StageState] = []
260
190
  for stage in STAGES:
261
191
  if stage.key == "preview":
@@ -424,24 +354,21 @@ def render(run_root: Path, *, as_json: bool) -> int:
424
354
  print(f"RUN STATUS ERROR: not a directory: {run_root}", file=sys.stderr)
425
355
  return 2
426
356
  facts = capture_run_facts(run_root=run_root)
427
- pointback_error = next(
428
- (
429
- error
430
- for error in facts.read_errors
431
- if error.artifact == "point_back" and error.code != "missing"
432
- ),
357
+ read_error = next(
358
+ (error for error in facts.read_errors if error.code != "missing"),
433
359
  None,
434
360
  )
435
- if pointback_error is not None:
361
+ if read_error is not None:
436
362
  print(
437
- f"RUN STATUS ERROR: cannot read point-back.md: {pointback_error.message}",
363
+ f"RUN STATUS ERROR: cannot read {read_error.path.name}: "
364
+ f"{read_error.message}",
438
365
  file=sys.stderr,
439
366
  )
440
367
  return 2
441
368
  snapshot = facts.preview or inspect_preview(run_root / "preview")
442
369
  states = inspect_run(run_root, snapshot, facts)
443
370
  action = next_action(states, run_root, snapshot, facts)
444
- vnext = inspect_vnext(run_root)
371
+ vnext = inspect_vnext(run_root, facts)
445
372
  payload = {
446
373
  "run_root": str(run_root),
447
374
  "stages": [
@@ -144,7 +144,7 @@ from design_playbook.scripts.shaping_log import ( # noqa: E402
144
144
  from design_playbook.scripts.dd_entries import DD_HEADING # noqa: E402
145
145
  from design_playbook.scripts.g10_design_decisions import check_g10 # noqa: E402
146
146
  from design_playbook.scripts.repair_rounds import check_rounds # noqa: E402
147
- from design_playbook.scripts.run_profile import parse_run_profile # noqa: E402
147
+ from design_playbook.scripts.run_profile import validate_run_profile # noqa: E402
148
148
 
149
149
  # vNext S3 gates: method-semantics keys (G6-adjacent), interaction-track
150
150
  # dimension annotations (G2-adjacent), sampling-matrix gaps (G11), and the
@@ -198,6 +198,17 @@ def run(
198
198
  spec_path=Path(spec_path), pointback_path=Path(pb_path),
199
199
  preview_dir=pd, evidence_dir=ed, run_root=rr,
200
200
  )
201
+ profile = facts.run_profile
202
+ if profile is not None:
203
+ for error in validate_run_profile(profile):
204
+ errs.append(finding(
205
+ "G12.run_profile",
206
+ f"run-profile invalid: {error}",
207
+ owner="plan.md#run-profile",
208
+ expected="supported v1 run-profile with valid tier and confirmation",
209
+ actual=error,
210
+ repair="Fix the run-profile block before running vNext gates",
211
+ ))
201
212
  operational_errors = tuple(
202
213
  error for error in facts.read_errors if error.artifact != "manifest"
203
214
  )
@@ -232,7 +243,10 @@ def run(
232
243
  # vNext S6: the effective tier P3 makes the matrix block itself
233
244
  # mandatory (loop-prototype 1.2 "sampling matrix fully executed").
234
245
  errs += check_sampling_matrix(
235
- pointback_text, spec_text, evidence_dir=ed, tier=_plan_tier(rr))
246
+ pointback_text, spec_text, evidence_dir=ed, tier=(
247
+ effective_tier(profile.tier, profile.upgrades)
248
+ if profile is not None else None
249
+ ))
236
250
  # G2 dimensions (vNext S3): dimension/face/basis annotations on findings
237
251
  # — subjective faces are judgment class (advisory only, source declared).
238
252
  errs += check_dimensions(pointback_text)
@@ -309,20 +323,23 @@ def run(
309
323
  sd = Path(shaping_dir) if shaping_dir else (
310
324
  rr / "shaping" if rr is not None else None
311
325
  )
312
- shaping_events: list[dict] | None = None
326
+ shaping_events: list[dict] | None = (
327
+ list(facts.shaping_events) if facts.shaping_events is not None else None
328
+ )
313
329
  if sd is not None and (sd / Path(SHAPING_LOG).name).is_file():
314
330
  errs += check_g9(
315
331
  sd,
316
332
  project_dir=Path(contract_project) if contract_project else None,
317
333
  run_dir=Path(contract_run) if contract_run else rr,
318
334
  )
319
- # G10 consumes the same session events for the T3 upstream-route
320
- # signal; on a malformed log G9 owns the diagnostic and G10 skips.
321
- try:
322
- shaping_events = parse_shaping_log(
323
- (sd / SHAPING_LOG).read_text(encoding="utf-8"))
324
- except (OSError, UnicodeError, ShapingLogError):
325
- shaping_events = None
335
+ # Explicit --shaping-dir may point outside the captured run root.
336
+ # Default discovery already parsed the same log into RunFacts.
337
+ if shaping_dir is not None:
338
+ try:
339
+ shaping_events = parse_shaping_log(
340
+ (sd / SHAPING_LOG).read_text(encoding="utf-8"))
341
+ except (OSError, UnicodeError, ShapingLogError):
342
+ shaping_events = None
326
343
 
327
344
  # G10 (conditional): a decision report carrying DD entry blocks engages
328
345
  # the design-decision gate (top block stays verbatim; reports without
@@ -330,10 +347,10 @@ def run(
330
347
  dr_g10 = dr if dr is not None else (
331
348
  rr / "decision-report.md" if rr is not None else None
332
349
  )
333
- report_text = ""
334
- if dr_g10 is not None and dr_g10.is_file():
350
+ report_text = facts.decision_report_text if dr is None else ""
351
+ if dr is not None and dr.is_file():
335
352
  try:
336
- report_text = dr_g10.read_text(encoding="utf-8")
353
+ report_text = dr.read_text(encoding="utf-8")
337
354
  except (OSError, UnicodeError):
338
355
  report_text = ""
339
356
  if report_text and DD_HEADING.search(report_text):
@@ -343,18 +360,19 @@ def run(
343
360
  preview_dir=pd,
344
361
  shaping_events=shaping_events,
345
362
  pointback_text=pointback_text,
346
- baseline_state=_load_json_tolerant(
347
- rr / "design-baseline" / "state.json"
348
- ) if rr is not None else None,
349
- run_profile_tier=_plan_tier(rr),
363
+ baseline_state=facts.baseline_state,
364
+ run_profile_tier=(
365
+ effective_tier(profile.tier, profile.upgrades)
366
+ if profile is not None else None
367
+ ),
350
368
  )
351
369
 
352
370
  # G8 run level (vNext S3): a craft-guard.md in the run root is checked
353
371
  # against the registry (shared parser). P2/P3 demand one audit row per
354
372
  # advisory entry; P1 and tier-less legacy runs keep the subset freedom.
355
- if rr is not None and (rr / "craft-guard.md").is_file():
373
+ if facts.craft_guard_exists:
356
374
  try:
357
- craft_text = (rr / "craft-guard.md").read_text(encoding="utf-8")
375
+ craft_text = facts.craft_guard_text
358
376
  registry_entries, _ = load_registry()
359
377
  except (OSError, UnicodeError):
360
378
  errs.append(finding(
@@ -367,13 +385,15 @@ def run(
367
385
  repair="Restore the files or drop the audit log",
368
386
  ))
369
387
  else:
370
- errs += check_g8_run(craft_text, registry_entries, _plan_tier(rr))
388
+ errs += check_g8_run(craft_text, registry_entries, (
389
+ effective_tier(profile.tier, profile.upgrades)
390
+ if profile is not None else None
391
+ ))
371
392
 
372
393
  # G12 tier boundary + escalation signals (vNext S4): fires when plan.md
373
394
  # carries a run-profile block; legacy runs without the block are not
374
395
  # re-checked. The contract diff basis is the G7 bind snapshot; without
375
396
  # contract paths the route / decision / blocking faces still fire.
376
- profile = _plan_profile(rr)
377
397
  if profile is not None:
378
398
  touch = None
379
399
  bound_criteria = None
@@ -386,10 +406,13 @@ def run(
386
406
  touch = contract_touch(bound, effective)
387
407
  bound_criteria = sum(
388
408
  1 for path in bound if CRITERION_PATH.match(path))
389
- dd_explore = bool(
409
+ dd_explore = any(
410
+ entry.tier == "explore" for entry in facts.decision_entries
411
+ ) if dr is None else bool(
390
412
  report_text and DD_HEADING.search(report_text)
391
413
  and any(entry.tier == "explore"
392
- for entry in parse_dd_entries(report_text)))
414
+ for entry in parse_dd_entries(report_text))
415
+ )
393
416
  g12_errs, g12_warns, _signals = check_g12(
394
417
  profile,
395
418
  pointback_text=pointback_text,
@@ -403,40 +426,6 @@ def run(
403
426
  return errs, warns
404
427
 
405
428
 
406
- def _load_json_tolerant(path: Path) -> dict | None:
407
- try:
408
- import json
409
-
410
- data = json.loads(path.read_text(encoding="utf-8"))
411
- except (OSError, UnicodeError, ValueError):
412
- return None
413
- return data if isinstance(data, dict) else None
414
-
415
-
416
- def _plan_profile(run_root: Path | None):
417
- """The parsed run-profile block of a run root; None when absent."""
418
- if run_root is None:
419
- return None
420
- plan = run_root / "plan.md"
421
- try:
422
- return parse_run_profile(plan.read_text(encoding="utf-8"))
423
- except (OSError, UnicodeError):
424
- return None
425
-
426
-
427
- def _plan_tier(run_root: Path | None) -> str | None:
428
- """The run's effective tier: declared tier plus recorded S4 upgrades.
429
-
430
- G8/G10 consume this; after an escalation (run-profile upgrades event)
431
- the run walks the new tier's obligations, so the effective tier — not
432
- the intake declaration — is the gate input.
433
- """
434
- profile = _plan_profile(run_root)
435
- if profile is None:
436
- return None
437
- return effective_tier(profile.tier, profile.upgrades)
438
-
439
-
440
429
  def _parse_args(argv: list[str]) -> argparse.Namespace:
441
430
  parser = argparse.ArgumentParser(
442
431
  prog="validate_run.py",