design-playbook 0.22.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/codex/AGENTS.md +1 -1
- package/commands/run-status.md +1 -1
- package/mcp/evidence/handoff.py +9 -1
- package/mcp/evidence/test_handoff.py +51 -0
- package/mcp/evidence/test_handoff_i18n.py +10 -0
- package/mcp/preview/i18n.py +0 -47
- package/mcp/run_console/app.js +32 -5
- package/mcp/run_console/contract.py +17 -0
- package/mcp/run_console/fixtures/point-back-recirculate.md +7 -0
- package/mcp/run_console/repair_packet.py +44 -5
- package/mcp/run_console/snapshot_builder.py +19 -10
- package/mcp/run_console/snapshot_v1.schema.json +6 -1
- package/mcp/run_console/test_contract.py +28 -0
- package/mcp/run_console/test_parity.py +21 -10
- package/mcp/run_console/test_repair_packet.py +302 -12
- package/mcp/run_console/test_snapshot_builder.py +64 -0
- package/mcp/ui_locale.py +8 -1
- package/package.json +1 -1
- package/scripts/capability_receipt.py +50 -3
- package/scripts/run_continuation.py +39 -3
- package/scripts/status_projection.py +57 -0
package/codex/AGENTS.md
CHANGED
package/commands/run-status.md
CHANGED
|
@@ -16,7 +16,7 @@ python <plugin>/scripts/run_status.py [.scratch/<run>] [--json] [--list] [--scra
|
|
|
16
16
|
- `--json` emits machine-readable stages + `next` action, plus the continuation block: selected run, current phase, blocker, run integrity, and the Console capability receipt (`implementation` / `validation` / `availability` / `publicClaim`).
|
|
17
17
|
- `--list` only lists discovered runs.
|
|
18
18
|
|
|
19
|
-
For an eligible run the continuation names an explicit `open-console` command for the local, experimental, trial-gated Run Console — no authorized external trial or public release is claimed until the separately authorized read-only trial gate passes (ADR-0043). `run-status` never starts a server, daemon, or background process itself; an ineligible run reports the blocking reason and a safe fallback instead.
|
|
19
|
+
For an eligible run the continuation names an explicit `open-console` command for the local, experimental, trial-gated Run Console — no authorized external trial or public release is claimed until the separately authorized read-only trial gate passes (ADR-0043). `run-status` never starts a server, daemon, or background process itself; an ineligible run reports the blocking reason and a safe fallback instead. On Windows the emitted command line is PowerShell syntax (single-quoted literals behind the `&` call operator) — paste it into PowerShell, not cmd.exe.
|
|
20
20
|
|
|
21
21
|
## Done when
|
|
22
22
|
|
package/mcp/evidence/handoff.py
CHANGED
|
@@ -134,14 +134,22 @@ def _point_back_verdict(run_root: Path) -> str | None:
|
|
|
134
134
|
decision 4). Reading goes through the single verdict-syntax parser
|
|
135
135
|
(ADR-0025) that G3 and run status already share - never a second
|
|
136
136
|
parser here. ``None`` (missing, malformed, ambiguous, or repeated
|
|
137
|
-
verdict text) is never a Pass.
|
|
137
|
+
verdict text) is never a Pass. An ``audited:`` marker that is
|
|
138
|
+
false or ambiguous withholds the verdict too - the same fence
|
|
139
|
+
``verdict_of`` applies on the run-status surface, so the two
|
|
140
|
+
authorities can never disagree on one run. Legacy point-backs
|
|
141
|
+
without a marker stay readable on both surfaces.
|
|
138
142
|
"""
|
|
143
|
+
from design_playbook.scripts.audit_preferences import parse_audit_marker
|
|
139
144
|
from design_playbook.scripts.verdict_syntax import parse_verdict
|
|
140
145
|
|
|
141
146
|
try:
|
|
142
147
|
text = (run_root / "point-back.md").read_text(encoding="utf-8")
|
|
143
148
|
except (OSError, UnicodeError):
|
|
144
149
|
return None
|
|
150
|
+
marker = parse_audit_marker(text)
|
|
151
|
+
if marker.present and marker.audited is not True:
|
|
152
|
+
return None
|
|
145
153
|
return parse_verdict(text).canonical
|
|
146
154
|
|
|
147
155
|
|
|
@@ -311,6 +311,57 @@ class BuildStaticHandoffTests(unittest.TestCase):
|
|
|
311
311
|
["pass"] * 5 + ["not-applicable"] * 3,
|
|
312
312
|
)
|
|
313
313
|
|
|
314
|
+
def test_unaudited_marker_withholds_the_delivery_pass(self) -> None:
|
|
315
|
+
"""The audit-marker fence is shared with run status (``verdict_of``):
|
|
316
|
+
an ``audited: false`` or duplicated marker withholds the point-back
|
|
317
|
+
verdict here too, so the handoff credential and run status can
|
|
318
|
+
never disagree on one run. Legacy point-backs without a marker and
|
|
319
|
+
a single ``audited: true`` stay readable."""
|
|
320
|
+
import tempfile
|
|
321
|
+
|
|
322
|
+
faces = (
|
|
323
|
+
(
|
|
324
|
+
"audited-false",
|
|
325
|
+
"# point-back\n\naudited: false\n\n## Verdict\n\n**Pass.**\n",
|
|
326
|
+
),
|
|
327
|
+
(
|
|
328
|
+
"duplicated-marker",
|
|
329
|
+
"# point-back\n\naudited: true\naudited: false\n\n"
|
|
330
|
+
"## Verdict\n\n**Pass.**\n",
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
with tempfile.TemporaryDirectory() as tmp_s:
|
|
334
|
+
tmp = Path(tmp_s)
|
|
335
|
+
for label, point_back in faces:
|
|
336
|
+
with self.subTest(face=label):
|
|
337
|
+
run_root = _make_run(tmp, name=f"run-{label}")
|
|
338
|
+
(run_root / "point-back.md").write_text(
|
|
339
|
+
point_back, encoding="utf-8"
|
|
340
|
+
)
|
|
341
|
+
result = self._build(tmp, run_root)
|
|
342
|
+
payload = result.payload
|
|
343
|
+
# Confirmed round, complete capture, resolved gates -
|
|
344
|
+
# still no Pass: the marker fence outranks the verdict
|
|
345
|
+
# text.
|
|
346
|
+
self.assertEqual(payload["verdict"], "Recirculate")
|
|
347
|
+
self.assertEqual(payload["authority"], "confirmed-user")
|
|
348
|
+
|
|
349
|
+
def test_audited_marker_true_keeps_the_delivery_pass(self) -> None:
|
|
350
|
+
"""A single well-formed ``audited: true`` marker does not withhold
|
|
351
|
+
the verdict - only false/ambiguous markers do (legacy runs carry
|
|
352
|
+
no marker at all and stay readable on both surfaces)."""
|
|
353
|
+
import tempfile
|
|
354
|
+
|
|
355
|
+
with tempfile.TemporaryDirectory() as tmp_s:
|
|
356
|
+
tmp = Path(tmp_s)
|
|
357
|
+
run_root = _make_run(tmp)
|
|
358
|
+
(run_root / "point-back.md").write_text(
|
|
359
|
+
"# point-back\n\naudited: true\n\n## Verdict\n\n**Pass.**\n",
|
|
360
|
+
encoding="utf-8",
|
|
361
|
+
)
|
|
362
|
+
result = self._build(tmp, run_root)
|
|
363
|
+
self.assertEqual(result.payload["verdict"], "Pass")
|
|
364
|
+
|
|
314
365
|
def test_floor_failing_record_is_never_confirmed(self) -> None:
|
|
315
366
|
import tempfile
|
|
316
367
|
|
|
@@ -95,6 +95,16 @@ def test_invalid_explicit_locale_fails_before_writing(tmp_path):
|
|
|
95
95
|
assert list(tmp_path.iterdir()) == []
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
@pytest.mark.parametrize("blank", ["", " "])
|
|
99
|
+
def test_blank_explicit_locale_falls_back_like_unspecified(blank):
|
|
100
|
+
"""A blank explicit locale encodes absence (form/MCP callers), not a
|
|
101
|
+
bad locale: it must follow the env policy, not raise."""
|
|
102
|
+
with patch.dict("os.environ", {"LANG": "en_US.UTF-8"}, clear=True):
|
|
103
|
+
assert resolve_ui_locale(blank) == "en"
|
|
104
|
+
with patch.dict("os.environ", {}, clear=True):
|
|
105
|
+
assert resolve_ui_locale(blank) == "zh-CN"
|
|
106
|
+
|
|
107
|
+
|
|
98
108
|
def test_cli_exposes_explicit_locale(capsys):
|
|
99
109
|
with pytest.raises(SystemExit) as exit_info:
|
|
100
110
|
main(["--help"])
|
package/mcp/preview/i18n.py
CHANGED
|
@@ -12,19 +12,13 @@ from design_playbook.mcp.ui_locale import EN, ZH, resolve_ui_locale
|
|
|
12
12
|
|
|
13
13
|
_STRINGS: dict[str, dict[str, str]] = {
|
|
14
14
|
ZH: {
|
|
15
|
-
"region_label": "预览确认",
|
|
16
15
|
"round_n": "第 {n} 轮",
|
|
17
16
|
"annotate": "批注",
|
|
18
|
-
"pill_open": "评审确认",
|
|
19
17
|
"ready": "可确认",
|
|
20
|
-
"not_ready": "写意见或批注后再确认",
|
|
21
|
-
"drawer_aria": "批注与确认",
|
|
22
18
|
"drawer_title": "批注与确认",
|
|
23
19
|
"drawer_empty_title": "还没有批注",
|
|
24
20
|
"drawer_empty_desc": "用点选工具放置 Pin,按 B 框选区域,或按 D 圈画问题;也可在下方写整体意见",
|
|
25
21
|
"collapse": "收起",
|
|
26
|
-
"pin_toggle": "点选批注",
|
|
27
|
-
"pin_toggle_desc": "开启后点击页面元素添加批注锚点;再点一次或按 Esc 退出",
|
|
28
22
|
"duplicate_anchor": "该元素已是批注 {n},未重复添加",
|
|
29
23
|
"onboard_title": "标注速览(仅出现一次)",
|
|
30
24
|
"onboard_pick": "点「批注」后点选元素,按 B 拖拽框选,或按 R 临时测距",
|
|
@@ -32,18 +26,10 @@ _STRINGS: dict[str, dict[str, str]] = {
|
|
|
32
26
|
"onboard_submit": "提交确认",
|
|
33
27
|
"onboard_undo": "撤销批注(Shift 重做)",
|
|
34
28
|
"onboard_close": "知道了",
|
|
35
|
-
"pin_count": "已选 {n} 处",
|
|
36
|
-
"pin_on": "点选中 · 再点关闭",
|
|
37
|
-
"pin_off": "点选批注",
|
|
38
29
|
"anchor_num_pre": "锚点 ",
|
|
39
30
|
"anchor_num_post": " 的修改意见",
|
|
40
31
|
"anchor_placeholder": "这条要改什么",
|
|
41
32
|
"remove_num_pre": "移除锚点 ",
|
|
42
|
-
"pin_count_pre": "已选 ",
|
|
43
|
-
"pin_count_post": " 处",
|
|
44
|
-
# JS-side dynamic anchors (split pre/post because idx is a JS value):
|
|
45
|
-
"anchors_head": "选中批注",
|
|
46
|
-
"anchors_empty": "尚未选中元素。点上方「点选批注」后,再点页面上的元素。",
|
|
47
33
|
"field_label": "整体批注",
|
|
48
34
|
"field_hint": "请写整体意见或点选元素加批注后确认;空反馈会被拦下",
|
|
49
35
|
"field_placeholder": "对整页的总意见;页面元素的局部问题用「点选批注」",
|
|
@@ -55,14 +41,9 @@ _STRINGS: dict[str, dict[str, str]] = {
|
|
|
55
41
|
"terminate_desc": "终止整次预览会话(不是通过/修改);需在弹出层再次确认",
|
|
56
42
|
"abort_cancelled": "已取消终止",
|
|
57
43
|
"abort_popover_aria": "确认终止预览",
|
|
58
|
-
"quick_feedback_placeholder": "一句话反馈(宽屏)",
|
|
59
|
-
"ready_hint": "点击聚焦反馈区",
|
|
60
44
|
"draft": "保留批注,暂不决定",
|
|
61
45
|
"draft_desc": "关闭抽屉并保留批注,不提交本轮决定",
|
|
62
|
-
"locate": "定位到该元素",
|
|
63
|
-
"locate_anchor": "定位到该元素",
|
|
64
46
|
"remove": "移除",
|
|
65
|
-
"selected_n": "已选 {n} 处",
|
|
66
47
|
"anchor_note_label": "锚点批注 ({n}):",
|
|
67
48
|
"no_text": "(无文字)",
|
|
68
49
|
"done_title": "已记录",
|
|
@@ -144,31 +125,20 @@ _STRINGS: dict[str, dict[str, str]] = {
|
|
|
144
125
|
"toast_drawer_open": "展开批注抽屉",
|
|
145
126
|
"toast_drawer_closed": "已收起批注抽屉(点击右侧把手或按 [ 展开)",
|
|
146
127
|
"toast_lang": "已切换至中文模式",
|
|
147
|
-
"toast_undo": "已撤销上一笔圈画",
|
|
148
128
|
"toast_focus": "聚焦批注 #{n}",
|
|
149
129
|
"toast_vp": "已切换视口宽度",
|
|
150
130
|
"confirm_desc": "确认本轮通过并进入实现(Fill);需有批注或整体意见",
|
|
151
131
|
"revise": "需要修改",
|
|
152
132
|
"revise_desc": "提交修改意见,进入下一轮原型",
|
|
153
|
-
"floor_failure_empty": "confirm with no substantive feedback: empty feedback and no anchor with a non-empty comment",
|
|
154
|
-
"floor_failure_selector": "anchor missing non-empty selector and comment: selector={sel!r} comment={note!r}",
|
|
155
|
-
"log_anchor_missing": "锚点 #{n} 缺 selector 或 comment",
|
|
156
|
-
"self_check_passed": "FLOOR SELF-CHECK PASSED",
|
|
157
133
|
},
|
|
158
134
|
EN: {
|
|
159
|
-
"region_label": "Preview confirm",
|
|
160
135
|
"round_n": "Round {n}",
|
|
161
136
|
"annotate": "Annotate",
|
|
162
|
-
"pill_open": "Review & confirm",
|
|
163
137
|
"ready": "Ready",
|
|
164
|
-
"not_ready": "Add notes or pin to confirm",
|
|
165
|
-
"drawer_aria": "Annotate & confirm",
|
|
166
138
|
"drawer_title": "Annotations & confirm",
|
|
167
139
|
"drawer_empty_title": "No annotations yet",
|
|
168
140
|
"drawer_empty_desc": "Use pin mode to place a note, press B for a box, or D to draw; you can also write overall feedback below",
|
|
169
141
|
"collapse": "Collapse",
|
|
170
|
-
"pin_toggle": "Pick to annotate",
|
|
171
|
-
"pin_toggle_desc": "When on, click page elements to add annotation anchors; click again or press Esc to exit",
|
|
172
142
|
"duplicate_anchor": "Already annotated as #{n}; not added again",
|
|
173
143
|
"onboard_title": "Annotation quick tour (shown once)",
|
|
174
144
|
"onboard_pick": "Click Annotate to pick elements, press B to draw a box, or press R to measure spacing",
|
|
@@ -176,17 +146,10 @@ _STRINGS: dict[str, dict[str, str]] = {
|
|
|
176
146
|
"onboard_submit": "Submit confirm",
|
|
177
147
|
"onboard_undo": "Undo annotation (Shift to redo)",
|
|
178
148
|
"onboard_close": "Got it",
|
|
179
|
-
"pin_count": "{n} selected",
|
|
180
|
-
"pin_on": "Picking · click again to close",
|
|
181
|
-
"pin_off": "Pick to annotate",
|
|
182
149
|
"anchor_num_pre": "Anchor ",
|
|
183
150
|
"anchor_num_post": " revision note",
|
|
184
151
|
"anchor_placeholder": "What to change here",
|
|
185
152
|
"remove_num_pre": "Remove anchor ",
|
|
186
|
-
"pin_count_pre": "",
|
|
187
|
-
"pin_count_post": " selected",
|
|
188
|
-
"anchors_head": "Selected annotations",
|
|
189
|
-
"anchors_empty": "No element selected. Click \"Pick to annotate\" above, then click an element on the page.",
|
|
190
153
|
"field_label": "Overall feedback",
|
|
191
154
|
"field_hint": "Write overall notes or pick an element to annotate before confirming; empty feedback is blocked",
|
|
192
155
|
"field_placeholder": "Overall notes for the page; for a specific element use \"Pick to annotate\"",
|
|
@@ -198,14 +161,9 @@ _STRINGS: dict[str, dict[str, str]] = {
|
|
|
198
161
|
"terminate_desc": "Stops the whole preview session (not pass/fail); confirm again in the popover",
|
|
199
162
|
"abort_cancelled": "Abort cancelled",
|
|
200
163
|
"abort_popover_aria": "Confirm abort preview",
|
|
201
|
-
"quick_feedback_placeholder": "Quick note (wide layout)",
|
|
202
|
-
"ready_hint": "Click to focus feedback",
|
|
203
164
|
"draft": "Keep notes, decide later",
|
|
204
165
|
"draft_desc": "Close drawer and keep notes; do not submit a decision yet",
|
|
205
|
-
"locate": "Locate this element",
|
|
206
|
-
"locate_anchor": "Locate this element",
|
|
207
166
|
"remove": "Remove",
|
|
208
|
-
"selected_n": "{n} selected",
|
|
209
167
|
"anchor_note_label": "Anchor notes ({n}):",
|
|
210
168
|
"no_text": "(no text)",
|
|
211
169
|
"done_title": "Recorded",
|
|
@@ -287,16 +245,11 @@ _STRINGS: dict[str, dict[str, str]] = {
|
|
|
287
245
|
"toast_drawer_open": "Drawer opened",
|
|
288
246
|
"toast_drawer_closed": "Drawer collapsed (click the right tab or press [ )",
|
|
289
247
|
"toast_lang": "Switched to English",
|
|
290
|
-
"toast_undo": "Undone last drawing",
|
|
291
248
|
"toast_focus": "Focused note #{n}",
|
|
292
249
|
"toast_vp": "Viewport width switched",
|
|
293
250
|
"confirm_desc": "Approve this round and proceed to Fill; needs notes or annotations",
|
|
294
251
|
"revise": "Needs changes",
|
|
295
252
|
"revise_desc": "Submit revision notes and open the next prototype round",
|
|
296
|
-
"floor_failure_empty": "confirm with no substantive feedback: empty feedback and no anchor with a non-empty comment",
|
|
297
|
-
"floor_failure_selector": "anchor missing non-empty selector and comment: selector={sel!r} comment={note!r}",
|
|
298
|
-
"log_anchor_missing": "anchor #{n} missing selector or comment",
|
|
299
|
-
"self_check_passed": "FLOOR SELF-CHECK PASSED",
|
|
300
253
|
},
|
|
301
254
|
}
|
|
302
255
|
|
package/mcp/run_console/app.js
CHANGED
|
@@ -1867,6 +1867,29 @@
|
|
|
1867
1867
|
);
|
|
1868
1868
|
}
|
|
1869
1869
|
|
|
1870
|
+
function packetNextActionField(primary, field, missingMessage) {
|
|
1871
|
+
var projected = packetFromAssertion(primary);
|
|
1872
|
+
var result = projected.value && typeof projected.value === "object" ? projected.value : null;
|
|
1873
|
+
if (!result) return projected;
|
|
1874
|
+
if (result[field] !== undefined && result[field] !== null) {
|
|
1875
|
+
return packetFact(
|
|
1876
|
+
projected.availability,
|
|
1877
|
+
result[field],
|
|
1878
|
+
projected.reason,
|
|
1879
|
+
projected.sourceId
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
if (projected.availability === "known") {
|
|
1883
|
+
return packetGap(missingMessage, projected.sourceId);
|
|
1884
|
+
}
|
|
1885
|
+
return packetFact(
|
|
1886
|
+
projected.availability,
|
|
1887
|
+
null,
|
|
1888
|
+
projected.reason || packetReason(PACKET_NOT_PRODUCED, missingMessage),
|
|
1889
|
+
projected.sourceId
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1870
1893
|
function deriveRepairPacket(snapshot) {
|
|
1871
1894
|
var evaluation = snapshot.evaluation || {};
|
|
1872
1895
|
var selection = selectBlockingFinding(evaluation.findings);
|
|
@@ -1881,12 +1904,16 @@
|
|
|
1881
1904
|
declarationOwner: findingFields.declarationOwner,
|
|
1882
1905
|
repairIntent: findingFields.repairIntent,
|
|
1883
1906
|
nextOwner: packetNextOwner(primary),
|
|
1884
|
-
invalidatedEvidence:
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
resumeStage:
|
|
1907
|
+
invalidatedEvidence: packetNextActionField(
|
|
1908
|
+
primary, "invalidatedEvidence", PACKET_MSG_NO_INVALIDATED
|
|
1909
|
+
),
|
|
1910
|
+
resumeStage: packetNextActionField(
|
|
1911
|
+
primary, "resumeStage", PACKET_MSG_NO_RESUME_STAGE
|
|
1912
|
+
),
|
|
1888
1913
|
nextCommand: packetNextCommand(primary),
|
|
1889
|
-
recaptureRequirement:
|
|
1914
|
+
recaptureRequirement: packetNextActionField(
|
|
1915
|
+
primary, "recaptureRequirement", PACKET_MSG_NO_RECAPTURE
|
|
1916
|
+
),
|
|
1890
1917
|
};
|
|
1891
1918
|
}
|
|
1892
1919
|
|
|
@@ -61,6 +61,12 @@ _DYNAMIC_ASSERTION_PATHS = (
|
|
|
61
61
|
("nextActions", "alternatives"),
|
|
62
62
|
("limitations", "items"),
|
|
63
63
|
)
|
|
64
|
+
_RECIRCULATE_ACTION_ID = "action.repair-after-recirculate"
|
|
65
|
+
_RECIRCULATE_ACTION_FIELDS = (
|
|
66
|
+
"invalidatedEvidence",
|
|
67
|
+
"resumeStage",
|
|
68
|
+
"recaptureRequirement",
|
|
69
|
+
)
|
|
64
70
|
|
|
65
71
|
|
|
66
72
|
class SnapshotContractError(ValueError):
|
|
@@ -393,6 +399,17 @@ def _validate_invariants(document: dict[str, Any]) -> None:
|
|
|
393
399
|
_require_sorted_unique([item["id"] for item in _at(document, path)])
|
|
394
400
|
for assertion in assertions:
|
|
395
401
|
_validate_assertion(assertion, source_records)
|
|
402
|
+
primary = document["nextActions"]["primary"]
|
|
403
|
+
if (
|
|
404
|
+
primary["availability"] == "known"
|
|
405
|
+
and primary["result"]["actionId"] == _RECIRCULATE_ACTION_ID
|
|
406
|
+
):
|
|
407
|
+
result = primary["result"]
|
|
408
|
+
if any(field not in result for field in _RECIRCULATE_ACTION_FIELDS):
|
|
409
|
+
raise _SchemaViolation
|
|
410
|
+
invalidated = result["invalidatedEvidence"]
|
|
411
|
+
if invalidated is not None and len(invalidated) != len(set(invalidated)):
|
|
412
|
+
raise _SchemaViolation
|
|
396
413
|
|
|
397
414
|
source_set_hash = _source_set_hash(document["sources"]["items"])
|
|
398
415
|
if document["sources"]["sourceSetHash"] != source_set_hash:
|
|
@@ -19,6 +19,13 @@ disposition: blocking
|
|
|
19
19
|
|
|
20
20
|
**Recirculate.** A Pass is not earned until the blocking finding is closed.
|
|
21
21
|
|
|
22
|
+
## Invalidated evidence
|
|
23
|
+
|
|
24
|
+
invalidated:
|
|
25
|
+
- criterion: L6.3
|
|
26
|
+
artifacts: [evidence/L6.3-error.png]
|
|
27
|
+
reason: Fill repair changes the destructive-action confirmation surface
|
|
28
|
+
|
|
22
29
|
## Evidence ledger
|
|
23
30
|
|
|
24
31
|
```text
|
|
@@ -292,6 +292,39 @@ def _next_command(primary: object) -> dict[str, Any]:
|
|
|
292
292
|
)
|
|
293
293
|
|
|
294
294
|
|
|
295
|
+
def _next_action_field(
|
|
296
|
+
primary: object, field: str, missing_message: str
|
|
297
|
+
) -> dict[str, Any]:
|
|
298
|
+
"""Project one optional owner fact from the primary action."""
|
|
299
|
+
projected = _from_assertion(primary)
|
|
300
|
+
result = projected["value"] if isinstance(projected["value"], dict) else None
|
|
301
|
+
if result is None:
|
|
302
|
+
return projected
|
|
303
|
+
value = result.get(field)
|
|
304
|
+
if isinstance(value, (list, str)) and value:
|
|
305
|
+
return _fact(
|
|
306
|
+
availability=projected["availability"],
|
|
307
|
+
value=value,
|
|
308
|
+
reason=projected["reason"],
|
|
309
|
+
source_id=projected["sourceId"],
|
|
310
|
+
)
|
|
311
|
+
if isinstance(value, list):
|
|
312
|
+
return _fact(
|
|
313
|
+
availability=projected["availability"],
|
|
314
|
+
value=[],
|
|
315
|
+
reason=projected["reason"],
|
|
316
|
+
source_id=projected["sourceId"],
|
|
317
|
+
)
|
|
318
|
+
if projected["availability"] == "known":
|
|
319
|
+
return _gap(missing_message, source_id=projected["sourceId"])
|
|
320
|
+
return _fact(
|
|
321
|
+
availability=projected["availability"],
|
|
322
|
+
value=None,
|
|
323
|
+
reason=projected["reason"] or _reason(NOT_PRODUCED, missing_message),
|
|
324
|
+
source_id=projected["sourceId"],
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
295
328
|
def _copy_line(label: str, fact: Mapping[str, Any]) -> str:
|
|
296
329
|
availability = str(fact.get("availability") or "unknown")
|
|
297
330
|
reason = fact.get("reason")
|
|
@@ -316,6 +349,8 @@ def _copy_line(label: str, fact: Mapping[str, Any]) -> str:
|
|
|
316
349
|
else:
|
|
317
350
|
parts.append(f"{key}={item}")
|
|
318
351
|
rendered = "; ".join(parts) if parts else "(none)"
|
|
352
|
+
elif isinstance(value, list):
|
|
353
|
+
rendered = ", ".join(str(entry) for entry in value)
|
|
319
354
|
else:
|
|
320
355
|
rendered = str(value)
|
|
321
356
|
stale = ""
|
|
@@ -368,12 +403,16 @@ def derive_repair_packet(snapshot: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
368
403
|
"verdict": _from_assertion(evaluation["verdict"]),
|
|
369
404
|
**finding_fields,
|
|
370
405
|
"nextOwner": _next_owner(primary),
|
|
371
|
-
"invalidatedEvidence":
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
"resumeStage":
|
|
406
|
+
"invalidatedEvidence": _next_action_field(
|
|
407
|
+
primary, "invalidatedEvidence", MSG_NO_INVALIDATED
|
|
408
|
+
),
|
|
409
|
+
"resumeStage": _next_action_field(
|
|
410
|
+
primary, "resumeStage", MSG_NO_RESUME_STAGE
|
|
411
|
+
),
|
|
375
412
|
"nextCommand": _next_command(primary),
|
|
376
|
-
"recaptureRequirement":
|
|
413
|
+
"recaptureRequirement": _next_action_field(
|
|
414
|
+
primary, "recaptureRequirement", MSG_NO_RECAPTURE
|
|
415
|
+
),
|
|
377
416
|
}
|
|
378
417
|
packet["copyText"] = format_packet_copy_text(packet)
|
|
379
418
|
return packet
|
|
@@ -536,20 +536,29 @@ class _Builder:
|
|
|
536
536
|
preview_snapshot=facts.preview,
|
|
537
537
|
run_facts=facts,
|
|
538
538
|
).primary
|
|
539
|
+
action_result = {
|
|
540
|
+
"actionId": action.action_id,
|
|
541
|
+
"kind": action.kind.value,
|
|
542
|
+
"label": action.label,
|
|
543
|
+
"owner": {
|
|
544
|
+
"actor": action.owner.actor.value,
|
|
545
|
+
"role": action.owner.role,
|
|
546
|
+
},
|
|
547
|
+
"copyableAgentCommand": action.copyable_agent_command,
|
|
548
|
+
}
|
|
549
|
+
if action.action_id == "action.repair-after-recirculate":
|
|
550
|
+
action_result["invalidatedEvidence"] = (
|
|
551
|
+
list(action.invalidated_evidence)
|
|
552
|
+
if action.invalidated_evidence is not None
|
|
553
|
+
else None
|
|
554
|
+
)
|
|
555
|
+
action_result["resumeStage"] = action.resume_stage
|
|
556
|
+
action_result["recaptureRequirement"] = action.recapture_requirement
|
|
539
557
|
self._draft(
|
|
540
558
|
"next-actions.primary",
|
|
541
559
|
(_REF_STATUS,),
|
|
542
560
|
"known",
|
|
543
|
-
|
|
544
|
-
"actionId": action.action_id,
|
|
545
|
-
"kind": action.kind.value,
|
|
546
|
-
"label": action.label,
|
|
547
|
-
"owner": {
|
|
548
|
-
"actor": action.owner.actor.value,
|
|
549
|
-
"role": action.owner.role,
|
|
550
|
-
},
|
|
551
|
-
"copyableAgentCommand": action.copyable_agent_command,
|
|
552
|
-
},
|
|
561
|
+
action_result,
|
|
553
562
|
)
|
|
554
563
|
# The owner emits no alternatives; the empty list is owner-known.
|
|
555
564
|
|
|
@@ -506,7 +506,12 @@
|
|
|
506
506
|
},
|
|
507
507
|
"label": { "type": "string", "minLength": 1 },
|
|
508
508
|
"owner": { "$ref": "#/$defs/nextActionOwner" },
|
|
509
|
-
"copyableAgentCommand": { "$ref": "#/$defs/nullableString" }
|
|
509
|
+
"copyableAgentCommand": { "$ref": "#/$defs/nullableString" },
|
|
510
|
+
"invalidatedEvidence": {
|
|
511
|
+
"anyOf": [{ "$ref": "#/$defs/stringArray" }, { "type": "null" }]
|
|
512
|
+
},
|
|
513
|
+
"resumeStage": { "$ref": "#/$defs/nullableString" },
|
|
514
|
+
"recaptureRequirement": { "$ref": "#/$defs/nullableString" }
|
|
510
515
|
}
|
|
511
516
|
},
|
|
512
517
|
"limitationResult": {
|
|
@@ -283,6 +283,34 @@ class SnapshotContractShapeTests(unittest.TestCase):
|
|
|
283
283
|
self.assertEqual(validated, document)
|
|
284
284
|
self.assertIsNot(validated, document)
|
|
285
285
|
|
|
286
|
+
def test_recirculate_action_requires_owner_continuation_fields(self) -> None:
|
|
287
|
+
def recirculate() -> dict[str, object]:
|
|
288
|
+
document = _valid_snapshot()
|
|
289
|
+
result = document["nextActions"]["primary"]["result"] # type: ignore[index]
|
|
290
|
+
result.update(
|
|
291
|
+
actionId="action.repair-after-recirculate",
|
|
292
|
+
kind="agent-command",
|
|
293
|
+
label="Verdict is Recirculate — repair from point-back findings.",
|
|
294
|
+
invalidatedEvidence=["L6.3"],
|
|
295
|
+
resumeStage="ui-evaluator",
|
|
296
|
+
recaptureRequirement=(
|
|
297
|
+
"Recapture only invalidated evidence, then re-run ui-evaluator."
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
return document
|
|
301
|
+
|
|
302
|
+
self.assertEqual(validate_snapshot(recirculate()), recirculate())
|
|
303
|
+
|
|
304
|
+
missing = recirculate()
|
|
305
|
+
del missing["nextActions"]["primary"]["result"]["resumeStage"] # type: ignore[index]
|
|
306
|
+
duplicated = recirculate()
|
|
307
|
+
duplicated["nextActions"]["primary"]["result"]["invalidatedEvidence"] = [ # type: ignore[index]
|
|
308
|
+
"L6.3", "L6.3"
|
|
309
|
+
]
|
|
310
|
+
for document in (missing, duplicated):
|
|
311
|
+
with self.subTest(document=document):
|
|
312
|
+
_assert_contract_invalid(self, document)
|
|
313
|
+
|
|
286
314
|
def test_s03_rejects_missing_and_unknown_fields_at_fixed_boundaries(self) -> None:
|
|
287
315
|
cases: list[dict[str, object]] = []
|
|
288
316
|
missing = _valid_snapshot()
|
|
@@ -414,18 +414,29 @@ class PassParityTest(_ParityTestCase):
|
|
|
414
414
|
)
|
|
415
415
|
|
|
416
416
|
# nextActions: structured next-action owner, alternatives owner-known.
|
|
417
|
+
expected_primary = {
|
|
418
|
+
"actionId": primary.action_id,
|
|
419
|
+
"kind": primary.kind.value,
|
|
420
|
+
"label": primary.label,
|
|
421
|
+
"owner": {
|
|
422
|
+
"actor": primary.owner.actor.value,
|
|
423
|
+
"role": primary.owner.role,
|
|
424
|
+
},
|
|
425
|
+
"copyableAgentCommand": primary.copyable_agent_command,
|
|
426
|
+
}
|
|
427
|
+
if primary.invalidated_evidence is not None:
|
|
428
|
+
expected_primary["invalidatedEvidence"] = list(
|
|
429
|
+
primary.invalidated_evidence
|
|
430
|
+
)
|
|
431
|
+
if primary.resume_stage is not None:
|
|
432
|
+
expected_primary["resumeStage"] = primary.resume_stage
|
|
433
|
+
if primary.recapture_requirement is not None:
|
|
434
|
+
expected_primary["recaptureRequirement"] = (
|
|
435
|
+
primary.recapture_requirement
|
|
436
|
+
)
|
|
417
437
|
self.assertEqual(
|
|
418
438
|
document["nextActions"]["primary"]["result"],
|
|
419
|
-
|
|
420
|
-
"actionId": primary.action_id,
|
|
421
|
-
"kind": primary.kind.value,
|
|
422
|
-
"label": primary.label,
|
|
423
|
-
"owner": {
|
|
424
|
-
"actor": primary.owner.actor.value,
|
|
425
|
-
"role": primary.owner.role,
|
|
426
|
-
},
|
|
427
|
-
"copyableAgentCommand": primary.copyable_agent_command,
|
|
428
|
-
},
|
|
439
|
+
expected_primary,
|
|
429
440
|
)
|
|
430
441
|
self.assertEqual(document["nextActions"]["alternatives"], [])
|
|
431
442
|
|