agent-bios 0.14.0 → 0.16.0

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.
Files changed (60) hide show
  1. package/DEPENDENCIES.md +35 -12
  2. package/README.md +346 -31
  3. package/claude/CLAUDE.md +2 -2
  4. package/claude/agents/frontier.md +1 -1
  5. package/claude/agents/sweep.md +3 -3
  6. package/claude/agents/workhorse.md +2 -2
  7. package/claude/guides/claude-prompting.md +119 -34
  8. package/claude/guides/cli-multi-model-workflow.md +33 -15
  9. package/claude/guides/gpt-prompting.md +148 -28
  10. package/claude/guides/review-request.md +27 -0
  11. package/claude/guides/session-distill-workflow.md +54 -2
  12. package/claude/guides/slide-writing/RUNBOOK.md +137 -0
  13. package/claude/guides/slide-writing/scripts/pair.py +979 -0
  14. package/claude/guides/slide-writing/scripts/render.mjs +82 -0
  15. package/claude/guides/slide-writing.md +195 -0
  16. package/claude/guides/svg-visualization-guide.md +9 -0
  17. package/claude/guides/verification-discipline.md +5 -1
  18. package/claude/hooks/tooling-gotchas-hook.py +7 -5
  19. package/codex/AGENTS.md +2 -2
  20. package/codex/agents/frontier.toml +2 -1
  21. package/codex/agents/reviewer.toml +1 -1
  22. package/codex/agents/sweep.toml +3 -3
  23. package/codex/agents/workhorse.toml +1 -1
  24. package/codex/config-additions.toml +1 -1
  25. package/codex/guides/claude-prompting.md +119 -34
  26. package/codex/guides/cli-multi-model-workflow.md +33 -15
  27. package/codex/guides/gpt-prompting.md +148 -28
  28. package/codex/guides/review-request.md +27 -0
  29. package/codex/guides/session-distill-workflow.md +54 -2
  30. package/codex/guides/slide-writing/RUNBOOK.md +137 -0
  31. package/codex/guides/slide-writing/scripts/pair.py +979 -0
  32. package/codex/guides/slide-writing/scripts/render.mjs +82 -0
  33. package/codex/guides/slide-writing.md +195 -0
  34. package/codex/guides/svg-visualization-guide.md +9 -0
  35. package/codex/guides/verification-discipline.md +5 -1
  36. package/compose/assemble.py +290 -14
  37. package/compose/bootstrap/SKILL.md +119 -0
  38. package/compose/check-domains.py +102 -9
  39. package/compose/corpus-state.py +1174 -0
  40. package/compose/corpus.py +387 -0
  41. package/compose/corpus_catalog.py +882 -0
  42. package/compose/corpus_install.py +1617 -0
  43. package/compose/corpus_session.py +726 -0
  44. package/compose/corpus_store.py +1414 -0
  45. package/compose/corpus_transaction.py +236 -0
  46. package/compose/corpus_ui.py +644 -0
  47. package/compose/domains.json +101 -100
  48. package/compose/write-update-cache.py +53 -0
  49. package/install.sh +174 -24
  50. package/launch/agent-launch.py +1327 -184
  51. package/launch/agent-launch.toml +12 -16
  52. package/launch/i18n/en.toml +113 -7
  53. package/launch/i18n/ja.toml +113 -7
  54. package/launch/i18n/ko.toml +113 -7
  55. package/learn/collect-learning.py +46 -19
  56. package/learn/migrate-learnings.py +10 -1
  57. package/package.json +13 -3
  58. package/provenance.json +1 -1
  59. package/session-cost.py +22 -2
  60. package/wrappers/codex-helm.sh +3 -3
@@ -0,0 +1,979 @@
1
+ #!/usr/bin/env python3
2
+ """Immutable writer/reader/judge jobs consuming the slide criterion guide.
3
+
4
+ The semantic criteria live only in the primary guide beside this companion
5
+ directory. This program deliberately validates structure, snapshots, and
6
+ provenance; it does not score a deck.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import hashlib
12
+ import json
13
+ import math
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import uuid
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+
25
+ PROTOCOL = "slide-pair/v1"
26
+ CRITERION_RE = re.compile(
27
+ r"<!-- criterion:([^\s>]+) -->(?:\r?\n)?(.*?)<!-- /criterion -->", re.DOTALL
28
+ )
29
+ LEADING_FRONTMATTER_RE = re.compile(
30
+ r"\A---[ \t]*\r?\n.*?\r?\n---[ \t]*(?:\r?\n|\Z)", re.DOTALL
31
+ )
32
+ RESERVED_CRITERION_MARKER_RE = re.compile(r"<!--\s*(?:criterion\b[^>]*|/criterion\b[^>]*)-->")
33
+ OPEN_CRITERION_MARKER_RE = re.compile(r"<!-- criterion:([^\s>]+) -->")
34
+ CLOSE_CRITERION_MARKER_RE = re.compile(r"<!-- /criterion -->")
35
+ # This is the sole structural authority for actor-provided item fields. Dynamic
36
+ # page, source-line, and criterion membership checks remain code-owned below.
37
+ RESPONSE_PROPERTY_MAP: dict[str, dict[str, dict[str, Any]]] = {
38
+ "observation_page": {
39
+ "page_id": {"type": "string", "nonempty": True},
40
+ "text_reading": {"type": "string", "nonempty": True},
41
+ "visual_reading": {"type": "string", "nonempty": True},
42
+ "uncertainties": {"type": "string", "nonempty": False},
43
+ },
44
+ "judge_item": {
45
+ "criterion_id": {"type": "string", "nonempty": True},
46
+ "verdict": {
47
+ "type": "string",
48
+ "nonempty": True,
49
+ "enum": ["satisfied", "revise", "uncertain", "not_applicable"],
50
+ },
51
+ "covered_pages": {"type": "array", "items": "string", "nonempty": True},
52
+ "evidence_refs": {"type": "array", "items": "string", "nonempty": True},
53
+ "reason": {"type": "string", "nonempty": True},
54
+ "proposed_change": {"type": "string", "nonempty": False},
55
+ },
56
+ }
57
+ OBSERVATION_FIELDS = tuple(RESPONSE_PROPERTY_MAP["observation_page"])
58
+ JUDGE_ITEM_FIELDS = tuple(RESPONSE_PROPERTY_MAP["judge_item"])
59
+
60
+
61
+ class ContractError(Exception):
62
+ pass
63
+
64
+
65
+ def sha256_bytes(value: bytes) -> str:
66
+ return hashlib.sha256(value).hexdigest()
67
+
68
+
69
+ def sha256_file(path: Path) -> str:
70
+ if not path.is_file():
71
+ raise ContractError(f"required file is missing: {path}")
72
+ return sha256_bytes(path.read_bytes())
73
+
74
+
75
+ def canonical(value: Any) -> bytes:
76
+ return (json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
77
+
78
+
79
+ def write_atomic(path: Path, data: bytes) -> None:
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as handle:
82
+ handle.write(data)
83
+ temp_name = handle.name
84
+ os.replace(temp_name, path)
85
+
86
+
87
+ def read_json(path: Path, label: str) -> Any:
88
+ try:
89
+ return json.loads(path.read_text(encoding="utf-8"))
90
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
91
+ raise ContractError(f"invalid {label}: {path}") from exc
92
+
93
+
94
+ def require_object(value: Any, label: str, keys: set[str] | None = None) -> dict[str, Any]:
95
+ if not isinstance(value, dict):
96
+ raise ContractError(f"{label} must be a JSON object")
97
+ if keys is not None and set(value) != keys:
98
+ raise ContractError(f"{label} has unsupported, missing, or runtime-owned fields")
99
+ return value
100
+
101
+
102
+ def require_string(value: Any, label: str, nonempty: bool = False) -> str:
103
+ if not isinstance(value, str) or (nonempty and not value.strip()):
104
+ raise ContractError(f"{label} must be" + (" a nonempty" if nonempty else "") + " string")
105
+ return value
106
+
107
+
108
+ def require_number(value: Any, label: str) -> float | int:
109
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
110
+ raise ContractError(f"{label} must be a finite number")
111
+ return value
112
+
113
+
114
+ def property_schema(kind: str) -> dict[str, Any]:
115
+ properties = RESPONSE_PROPERTY_MAP[kind]
116
+ return {"type": "object", "required": list(properties), "properties": properties}
117
+
118
+
119
+ def validate_properties(value: Any, label: str, kind: str) -> dict[str, Any]:
120
+ properties = RESPONSE_PROPERTY_MAP[kind]
121
+ row = require_object(value, label, set(properties))
122
+ for field, rule in properties.items():
123
+ field_label = f"{label} {field}"
124
+ field_value = row[field]
125
+ if rule["type"] == "string":
126
+ require_string(field_value, field_label, bool(rule["nonempty"]))
127
+ if "enum" in rule and field_value not in rule["enum"]:
128
+ raise ContractError(f"{field_label} is invalid")
129
+ elif rule["type"] == "array":
130
+ if not isinstance(field_value, list) or (rule["nonempty"] and not field_value):
131
+ raise ContractError(f"{field_label} must be" + (" a nonempty" if rule["nonempty"] else "") + " array")
132
+ if rule.get("items") == "string" and any(not isinstance(item, str) for item in field_value):
133
+ raise ContractError(f"{field_label} items must be strings")
134
+ else: # Defensive: an internal schema edit must not silently weaken validation.
135
+ raise ContractError(f"unsupported response property type for {field}")
136
+ return row
137
+
138
+
139
+ def template_value(rule: dict[str, Any]) -> Any:
140
+ if "enum" in rule:
141
+ return "uncertain" if "uncertain" in rule["enum"] else rule["enum"][0]
142
+ if rule["type"] == "array":
143
+ return []
144
+ return "<nonempty string>" if rule["nonempty"] else ""
145
+
146
+
147
+ def typed_item_template(kind: str) -> dict[str, Any]:
148
+ return {field: template_value(rule) for field, rule in RESPONSE_PROPERTY_MAP[kind].items()}
149
+
150
+
151
+ def base_paths(base: Path) -> tuple[Path, Path, Path]:
152
+ """Return the primary guide and runtime members for a companion directory."""
153
+ base = base.resolve()
154
+ guide = base.parent / f"{base.name}.md"
155
+ script = base / "scripts" / "pair.py"
156
+ renderer = base / "scripts" / "render.mjs"
157
+ return guide, script, renderer
158
+
159
+
160
+ def validate_criterion_markers(data: str) -> None:
161
+ """Reject malformed, nested, or unbalanced reserved criterion boundaries."""
162
+ open_id: str | None = None
163
+ for marker in RESERVED_CRITERION_MARKER_RE.finditer(data):
164
+ token = marker.group(0)
165
+ opening = OPEN_CRITERION_MARKER_RE.fullmatch(token)
166
+ closing = CLOSE_CRITERION_MARKER_RE.fullmatch(token)
167
+ if opening is None and closing is None:
168
+ raise ContractError("criterion guide has an invalid reserved criterion marker")
169
+ if opening is not None:
170
+ if open_id is not None:
171
+ raise ContractError("criterion guide has nested or unbalanced criterion markers")
172
+ open_id = opening.group(1)
173
+ elif open_id is None:
174
+ raise ContractError("criterion guide has a closing criterion marker without an opening marker")
175
+ else:
176
+ open_id = None
177
+ if open_id is not None:
178
+ raise ContractError("criterion guide has an unclosed criterion marker")
179
+
180
+
181
+ def parse_criteria(data: str) -> list[dict[str, str]]:
182
+ """Parse the primary guide, ignoring only its bounded leading frontmatter."""
183
+ frontmatter = LEADING_FRONTMATTER_RE.match(data)
184
+ criteria_source = data[frontmatter.end():] if frontmatter else data
185
+ validate_criterion_markers(criteria_source)
186
+ criteria: list[dict[str, str]] = []
187
+ cursor = 0
188
+ seen: set[str] = set()
189
+ for match in CRITERION_RE.finditer(criteria_source):
190
+ outside = criteria_source[cursor:match.start()]
191
+ if outside.strip():
192
+ raise ContractError("criterion guide has substantive text outside criterion blocks")
193
+ criterion_id, text = match.group(1), match.group(2)
194
+ if not criterion_id or criterion_id in seen:
195
+ raise ContractError("criterion IDs must be nonempty, unique, and stable")
196
+ if not text.strip():
197
+ raise ContractError(f"criterion {criterion_id} is empty")
198
+ criteria.append({"id": criterion_id, "text": text})
199
+ seen.add(criterion_id)
200
+ cursor = match.end()
201
+ remainder = criteria_source[cursor:]
202
+ if remainder.strip():
203
+ raise ContractError("criterion guide has substantive text outside criterion blocks")
204
+ if not criteria:
205
+ raise ContractError("criterion guide has no criterion blocks")
206
+ return criteria
207
+
208
+
209
+ def protocol_fingerprints(base: Path) -> dict[str, str]:
210
+ _, _, renderer = base_paths(base)
211
+ # --base selects criteria/assets; it must not misidentify the executing engine.
212
+ return {"pair_py": sha256_file(Path(__file__).resolve()), "render_mjs": sha256_file(renderer)}
213
+
214
+
215
+ def oracle_for_guide(base: Path, guide_bytes: bytes) -> tuple[list[dict[str, str]], bytes]:
216
+ """Derive the job-only oracle from one exact primary-guide byte snapshot."""
217
+ try:
218
+ source = guide_bytes.decode("utf-8")
219
+ except UnicodeDecodeError as exc:
220
+ raise ContractError("criterion guide is not valid UTF-8") from exc
221
+ criteria = parse_criteria(source)
222
+ block_bytes = canonical(criteria)
223
+ oracle = {
224
+ "protocol": PROTOCOL,
225
+ "criteria": criteria,
226
+ "source_fingerprint": sha256_bytes(block_bytes),
227
+ "source_document_fingerprint": sha256_bytes(guide_bytes),
228
+ "source_document": source,
229
+ "bundle_fingerprint": sha256_bytes(canonical({"protocol": PROTOCOL, "criteria": criteria})),
230
+ "protocol_fingerprints": protocol_fingerprints(base),
231
+ }
232
+ return criteria, canonical(oracle)
233
+
234
+
235
+ def read_guide_snapshot(base: Path) -> tuple[Path, bytes, list[dict[str, str]], bytes]:
236
+ """Read the guide once, so its oracle, origin hash, and job copy agree exactly."""
237
+ guide, _, _ = base_paths(base)
238
+ try:
239
+ guide_bytes = guide.read_bytes()
240
+ except OSError as exc:
241
+ raise ContractError(f"cannot read criterion guide: {guide}") from exc
242
+ criteria, oracle_bytes = oracle_for_guide(base, guide_bytes)
243
+ return guide, guide_bytes, criteria, oracle_bytes
244
+
245
+
246
+ def check_guide(base: Path) -> list[dict[str, str]]:
247
+ """Validate the current source structure without creating a projection."""
248
+ _, _, criteria, _ = read_guide_snapshot(base)
249
+ return criteria
250
+
251
+
252
+ def common_criteria(criteria: list[dict[str, str]]) -> str:
253
+ """The sole renderer for the identical semantic block in all role packets."""
254
+ return "\n\n".join(f"<!-- criterion:{item['id']} -->\n{item['text']}<!-- /criterion -->" for item in criteria) + "\n"
255
+
256
+
257
+ def relative_job_path(job: Path, path: Path) -> str:
258
+ try:
259
+ return path.resolve().relative_to(job.resolve()).as_posix()
260
+ except ValueError as exc:
261
+ raise ContractError(f"path escapes job directory: {path}") from exc
262
+
263
+
264
+ def manifest_path(job: Path) -> Path:
265
+ return job / "manifest.json"
266
+
267
+
268
+ def load_manifest(job: Path) -> dict[str, Any]:
269
+ return require_object(read_json(manifest_path(job), "manifest"), "manifest")
270
+
271
+
272
+ def write_manifest(job: Path, manifest: dict[str, Any]) -> None:
273
+ write_atomic(manifest_path(job), canonical(manifest))
274
+
275
+
276
+ def packet_context(manifest: dict[str, Any]) -> str:
277
+ material = {
278
+ "job_id": manifest["job_id"],
279
+ "protocol": manifest["protocol"],
280
+ "origins": manifest["origins"],
281
+ "fingerprints": manifest["fingerprints"],
282
+ }
283
+ return sha256_bytes(canonical(material))
284
+
285
+
286
+ def writer_request(job: Path, manifest: dict[str, Any], criteria: list[dict[str, str]]) -> dict[str, Any]:
287
+ return {
288
+ "protocol": PROTOCOL,
289
+ "stage": "writer",
290
+ "request_id": manifest["requests"]["writer"],
291
+ "job_id": manifest["job_id"],
292
+ "context": packet_context(manifest),
293
+ "criteria": common_criteria(criteria),
294
+ "inputs": manifest["snapshots"],
295
+ "output": "output/deck.html",
296
+ }
297
+
298
+
299
+ def writer_markdown(request: dict[str, Any]) -> str:
300
+ assets = request["inputs"]["assets"]
301
+ asset_lines = "\n".join(f"- `{value}`" for value in assets) or "- (none)"
302
+ return (
303
+ "# Writer request\n\n"
304
+ "Use only the frozen source, work spec, and assets below. Write the deck HTML to `output/deck.html`. "
305
+ "Input paths are relative to the job root; from that HTML, asset URLs use `../input/assets/<name>`.\n\n"
306
+ f"- Source: `{request['inputs']['source']}`\n- Spec: `{request['inputs']['spec']}`\n- Assets:\n{asset_lines}\n\n"
307
+ "## Canonical criteria\n\n" + request["criteria"]
308
+ )
309
+
310
+
311
+ def reader_response_contract(page_ids: list[str]) -> dict[str, Any]:
312
+ return {
313
+ "type": "object",
314
+ "required": ["pages"],
315
+ "properties": {"pages": {"type": "array", "exact_members": page_ids, "items": property_schema("observation_page")}},
316
+ }
317
+
318
+
319
+ def judge_response_contract(criteria: list[dict[str, str]], page_ids: list[str], source_line_count: int) -> dict[str, Any]:
320
+ return {
321
+ "type": "object",
322
+ "required": ["items"],
323
+ "properties": {"items": {"type": "array", "exact_members": [item["id"] for item in criteria], "items": property_schema("judge_item")}},
324
+ "dynamic_constraints": {
325
+ "covered_pages": {"exact_members": page_ids},
326
+ "evidence_refs": {
327
+ "nonempty": True,
328
+ "allowed_exact": ["spec"],
329
+ "allowed_forms": [
330
+ f"source:L<n>, where 1 <= n <= {source_line_count}",
331
+ "page:<page_id>",
332
+ "measurement:<page_id>",
333
+ ],
334
+ "allowed_page_ids": page_ids,
335
+ },
336
+ },
337
+ }
338
+
339
+
340
+ def reader_response_template(page_ids: list[str]) -> dict[str, Any]:
341
+ pages = []
342
+ for page_id in page_ids:
343
+ item = typed_item_template("observation_page")
344
+ item["page_id"] = page_id
345
+ pages.append(item)
346
+ return {"pages": pages}
347
+
348
+
349
+ def judge_response_template(criteria: list[dict[str, str]], page_ids: list[str]) -> dict[str, Any]:
350
+ items = []
351
+ for criterion in criteria:
352
+ item = typed_item_template("judge_item")
353
+ item["criterion_id"] = criterion["id"]
354
+ item["covered_pages"] = list(page_ids)
355
+ item["evidence_refs"] = ["spec"]
356
+ items.append(item)
357
+ return {"items": items}
358
+
359
+
360
+ def reader_request(job: Path, manifest: dict[str, Any], criteria: list[dict[str, str]]) -> dict[str, Any]:
361
+ render = manifest.get("render")
362
+ if not isinstance(render, dict):
363
+ raise ContractError("reader request requires a completed render")
364
+ return {
365
+ "protocol": PROTOCOL,
366
+ "stage": "reader",
367
+ "request_id": manifest["requests"]["reader"],
368
+ "job_id": manifest["job_id"],
369
+ "context": packet_context(manifest),
370
+ "criteria": common_criteria(criteria),
371
+ "rendered_pages": render["pages"],
372
+ "rendered_artifacts": render["reader_artifacts"],
373
+ "response_contract": reader_response_contract(render["pages"]),
374
+ }
375
+
376
+
377
+ def reader_markdown(request: dict[str, Any]) -> str:
378
+ pages = "\n".join(f"- `{path}`" for path in request["rendered_artifacts"])
379
+ response = reader_response_template(request["rendered_pages"])
380
+ return "".join((
381
+ "# Reader request\n\n",
382
+ "Read only the listed rendered artifacts. Before any later source comparison, record what text and visual structure are observable for each page.\n\n",
383
+ f"## Rendered page IDs\n\n{canonical(request['rendered_pages']).decode('utf-8')}\n",
384
+ f"## Rendered artifacts\n\n{pages}\n\n## Canonical criteria\n\n{request['criteria']}",
385
+ "## Response contract\n\n```json\n", canonical(request["response_contract"]).decode("utf-8"), "```\n",
386
+ "## Response JSON\n\n```json\n", canonical(response).decode("utf-8"), "```\n",
387
+ ))
388
+
389
+
390
+ def source_lines(job: Path, manifest: dict[str, Any]) -> list[dict[str, Any]]:
391
+ source = job / manifest["snapshots"]["source"]
392
+ return [{"line": index, "text": value} for index, value in enumerate(source.read_text(encoding="utf-8").splitlines(), 1)]
393
+
394
+
395
+ def judge_request(job: Path, manifest: dict[str, Any], criteria: list[dict[str, str]]) -> dict[str, Any]:
396
+ observations = read_json(job / "observations.json", "observations")
397
+ frozen_source_lines = source_lines(job, manifest)
398
+ page_ids = manifest["render"]["pages"]
399
+ return {
400
+ "protocol": PROTOCOL,
401
+ "stage": "judge",
402
+ "request_id": manifest["requests"]["judge"],
403
+ "job_id": manifest["job_id"],
404
+ "context": packet_context(manifest),
405
+ "criteria": common_criteria(criteria),
406
+ "source_lines": frozen_source_lines,
407
+ "spec": read_json(job / manifest["snapshots"]["spec"], "frozen work spec"),
408
+ "observations": observations,
409
+ "rendered_artifacts": manifest["render"]["reader_artifacts"],
410
+ "response_contract": judge_response_contract(criteria, page_ids, len(frozen_source_lines)),
411
+ }
412
+
413
+
414
+ def judge_markdown(request: dict[str, Any]) -> str:
415
+ response = judge_response_template(
416
+ [{"id": item_id} for item_id in request["response_contract"]["properties"]["items"]["exact_members"]],
417
+ request["response_contract"]["dynamic_constraints"]["covered_pages"]["exact_members"],
418
+ )
419
+ return "".join((
420
+ "# Judge request\n\n",
421
+ "Use the frozen source, spec, observations, and rendered artifacts. Return one item for every canonical criterion.\n\n",
422
+ "## Canonical criteria\n\n", request["criteria"],
423
+ "## Frozen source lines\n\n```json\n", canonical(request["source_lines"]).decode("utf-8"), "```\n",
424
+ "## Frozen work spec\n\n```json\n", canonical(request["spec"]).decode("utf-8"), "```\n",
425
+ "## Fixed observations\n\n```json\n", canonical(request["observations"]).decode("utf-8"), "```\n",
426
+ "## Rendered artifacts\n\n```json\n", canonical(request["rendered_artifacts"]).decode("utf-8"), "```\n",
427
+ "## Response contract\n\n```json\n", canonical(request["response_contract"]).decode("utf-8"), "```\n",
428
+ "## Response JSON\n\n```json\n", canonical(response).decode("utf-8"), "```\n",
429
+ ))
430
+
431
+
432
+ def write_packet(path: Path, value: dict[str, Any], markdown_path: Path, markdown: str) -> None:
433
+ write_atomic(path, canonical(value))
434
+ write_atomic(markdown_path, markdown.encode("utf-8"))
435
+
436
+
437
+ def copy_snapshot(origin: Path, destination: Path) -> str:
438
+ if not origin.is_file():
439
+ raise ContractError(f"input must be a file: {origin}")
440
+ try:
441
+ source_bytes = origin.read_bytes()
442
+ except OSError as exc:
443
+ raise ContractError(f"cannot freeze input: {origin}") from exc
444
+ origin_hash = sha256_bytes(source_bytes)
445
+ write_atomic(destination, source_bytes)
446
+ if sha256_file(destination) != origin_hash:
447
+ raise ContractError(f"input changed while being frozen: {origin}")
448
+ return origin_hash
449
+
450
+
451
+ def command_check(args: argparse.Namespace) -> None:
452
+ check_guide(args.base.resolve())
453
+
454
+
455
+ def command_prepare(args: argparse.Namespace) -> None:
456
+ base = args.base.resolve()
457
+ guide, guide_bytes, criteria, oracle_bytes = read_guide_snapshot(base)
458
+ job = args.job.resolve()
459
+ if job.exists():
460
+ raise ContractError(f"job directory already exists: {job}")
461
+ source, spec = args.source.resolve(), args.spec.resolve()
462
+ if not source.is_file() or not spec.is_file():
463
+ raise ContractError("--source and --spec must name existing files")
464
+ read_json(spec, "work spec")
465
+ assets = [asset.resolve() for asset in args.asset]
466
+ basenames = [asset.name for asset in assets]
467
+ if len(set(basenames)) != len(basenames):
468
+ raise ContractError("asset basename collisions are not allowed")
469
+ for asset in assets:
470
+ if not asset.is_file():
471
+ raise ContractError(f"asset must be a file: {asset}")
472
+ job.mkdir(parents=True)
473
+ try:
474
+ source_dest = job / "input" / "source" / source.name
475
+ spec_dest = job / "input" / "spec.json"
476
+ asset_dests = [job / "input" / "assets" / asset.name for asset in assets]
477
+ source_hash = copy_snapshot(source, source_dest)
478
+ spec_hash = copy_snapshot(spec, spec_dest)
479
+ asset_hashes = {str(asset): {"path": relative_job_path(job, dest), "sha256": copy_snapshot(asset, dest)} for asset, dest in zip(assets, asset_dests)}
480
+ guide_dest = job / "input" / guide.name
481
+ oracle_json_dest = job / "input" / "ORACLE.json"
482
+ write_atomic(guide_dest, guide_bytes)
483
+ guide_hash = sha256_bytes(guide_bytes)
484
+ if sha256_file(guide_dest) != guide_hash:
485
+ raise ContractError(f"criterion guide changed while being frozen: {guide}")
486
+ write_atomic(oracle_json_dest, oracle_bytes)
487
+ manifest: dict[str, Any] = {
488
+ "protocol": PROTOCOL,
489
+ "job_id": str(uuid.uuid4()),
490
+ "state": "prepared",
491
+ "origins": {
492
+ "source": {"path": str(source), "sha256": source_hash},
493
+ "spec": {"path": str(spec), "sha256": spec_hash},
494
+ "assets": asset_hashes,
495
+ "guide": {"path": str(guide.resolve()), "sha256": guide_hash},
496
+ },
497
+ "snapshots": {
498
+ "source": relative_job_path(job, source_dest),
499
+ "spec": relative_job_path(job, spec_dest),
500
+ "assets": [relative_job_path(job, item) for item in asset_dests],
501
+ "guide": relative_job_path(job, guide_dest),
502
+ "oracle": relative_job_path(job, oracle_json_dest),
503
+ },
504
+ "fingerprints": {
505
+ "oracle": sha256_bytes(oracle_bytes),
506
+ "protocol": protocol_fingerprints(base),
507
+ },
508
+ "requests": {"writer": str(uuid.uuid4())},
509
+ }
510
+ (job / "output").mkdir()
511
+ request = writer_request(job, manifest, criteria)
512
+ write_packet(job / "writer-request.json", request, job / "writer.md", writer_markdown(request))
513
+ write_manifest(job, manifest)
514
+ except Exception:
515
+ # A failed creation is not a usable job, and no existing job was touched.
516
+ raise
517
+
518
+
519
+ def current_request(job: Path, manifest: dict[str, Any], stage: str, base: Path) -> dict[str, Any]:
520
+ criteria = frozen_criteria(job, manifest, base)
521
+ if stage == "writer":
522
+ return writer_request(job, manifest, criteria)
523
+ if stage == "reader":
524
+ return reader_request(job, manifest, criteria)
525
+ if stage == "judge":
526
+ return judge_request(job, manifest, criteria)
527
+ raise ContractError(f"unknown request stage: {stage}")
528
+
529
+
530
+ def assert_packet(job: Path, manifest: dict[str, Any], stage: str, base: Path) -> dict[str, Any]:
531
+ expected = current_request(job, manifest, stage, base)
532
+ name = f"{stage}-request.json"
533
+ actual_path = job / name
534
+ if not actual_path.is_file() or actual_path.read_bytes() != canonical(expected):
535
+ raise ContractError(f"{name} is stale or edited")
536
+ markdown = {"writer": writer_markdown, "reader": reader_markdown, "judge": judge_markdown}[stage](expected).encode("utf-8")
537
+ markdown_path = job / f"{stage}.md"
538
+ if not markdown_path.is_file() or markdown_path.read_bytes() != markdown:
539
+ raise ContractError(f"{markdown_path.name} is stale or edited")
540
+ return expected
541
+
542
+
543
+ def validate_measurements(path: Path) -> list[str]:
544
+ data = require_object(read_json(path, "measurements"), "measurements")
545
+ pages = data.get("pages")
546
+ if not isinstance(pages, list) or not pages:
547
+ raise ContractError("measurements must contain a nonempty pages list")
548
+ ids: list[str] = []
549
+ for position, page in enumerate(pages, 1):
550
+ page_obj = require_object(page, f"measurements page {position}")
551
+ page_id = require_string(page_obj.get("id"), f"measurements page {position} id", True)
552
+ require_string(page_obj.get("source_page"), f"measurements page {position} source_page")
553
+ require_string(page_obj.get("title"), f"measurements page {position} title")
554
+ require_number(page_obj.get("width"), f"measurements page {position} width")
555
+ require_number(page_obj.get("height"), f"measurements page {position} height")
556
+ text_items = page_obj.get("text")
557
+ if not isinstance(text_items, list) or not isinstance(page_obj.get("outside"), list):
558
+ raise ContractError("measurements pages require text and outside lists")
559
+ for text_position, text_item in enumerate(text_items, 1):
560
+ text_obj = require_object(text_item, f"measurements text {position}.{text_position}")
561
+ require_string(text_obj.get("text"), f"measurements text {position}.{text_position} text")
562
+ for field in ("font_px", "x", "y", "width", "height"):
563
+ require_number(text_obj.get(field), f"measurements text {position}.{text_position} {field}")
564
+ require_string(text_obj.get("alignment"), f"measurements text {position}.{text_position} alignment")
565
+ ids.append(page_id)
566
+ if len(set(ids)) != len(ids):
567
+ raise ContractError("rendered page IDs must be unique")
568
+ return ids
569
+
570
+
571
+ def render_artifacts(job: Path, page_ids: list[str]) -> dict[str, Any]:
572
+ render = job / "render"
573
+ files = [render / "deck.pdf", render / "measurements.json"]
574
+ files.extend(render / f"page-{index:04d}.png" for index in range(1, len(page_ids) + 1))
575
+ for file in files:
576
+ if not file.is_file() or file.stat().st_size == 0:
577
+ raise ContractError(f"renderer did not produce required artifact: {file.name}")
578
+ return {relative_job_path(job, file): sha256_file(file) for file in files}
579
+
580
+
581
+ def declared_page_count(job: Path, manifest: dict[str, Any]) -> int | None:
582
+ """Honor an explicit positive integer pages contract without inferring one."""
583
+ spec = read_json(job / manifest["snapshots"]["spec"], "frozen work spec")
584
+ if isinstance(spec, dict):
585
+ value = spec.get("pages")
586
+ if isinstance(value, int) and not isinstance(value, bool) and value > 0:
587
+ return value
588
+ return None
589
+
590
+
591
+ def executable_path(value: Path, label: str) -> str:
592
+ """Resolve an executable explicitly while preserving a PATH-style CLI value."""
593
+ raw = str(value)
594
+ if os.sep not in raw:
595
+ found = shutil.which(raw)
596
+ if found:
597
+ return found
598
+ path = value.resolve()
599
+ if not path.is_file():
600
+ raise ContractError(f"{label} executable is missing: {value}")
601
+ return str(path)
602
+
603
+
604
+ def command_render(args: argparse.Namespace) -> None:
605
+ job = args.job.resolve()
606
+ base = args.base.resolve()
607
+ manifest = load_manifest(job)
608
+ if manifest.get("state") != "prepared" or "render" in manifest:
609
+ raise ContractError("render is allowed once for a prepared job")
610
+ preflight(job, manifest, base)
611
+ assert_packet(job, manifest, "writer", base)
612
+ output_html = job / "output" / "deck.html"
613
+ if not output_html.is_file() or output_html.stat().st_size == 0:
614
+ raise ContractError("writer output/deck.html is required before rendering")
615
+ sealed = job / "sealed"
616
+ render = job / "render"
617
+ if sealed.exists() or render.exists():
618
+ raise ContractError("sealed or render output already exists; create a new job revision")
619
+ (sealed / "output").mkdir(parents=True)
620
+ shutil.copyfile(output_html, sealed / "output" / "deck.html")
621
+ if manifest["snapshots"]["assets"]:
622
+ shutil.copytree(job / "input" / "assets", sealed / "input" / "assets")
623
+ render.mkdir()
624
+ _, _, renderer = base_paths(base)
625
+ env = os.environ.copy()
626
+ env["SLIDE_PLAYWRIGHT_MODULE"] = str(args.playwright.resolve())
627
+ env["SLIDE_BROWSER_EXECUTABLE"] = str(args.browser.resolve())
628
+ command = [executable_path(args.node, "node"), str(renderer.resolve()), str((sealed / "output" / "deck.html").resolve()), str(render.resolve()), str(sealed.resolve())]
629
+ try:
630
+ result = subprocess.run(command, shell=False, cwd=str(base), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False)
631
+ except OSError as exc:
632
+ raise ContractError(f"renderer could not start: {exc.strerror or exc}") from exc
633
+ write_atomic(render / "stdout.txt", (result.stdout or "").encode("utf-8"))
634
+ if result.returncode != 0:
635
+ raise ContractError(f"renderer exited with status {result.returncode}")
636
+ page_ids = validate_measurements(render / "measurements.json")
637
+ expected_pages = declared_page_count(job, manifest)
638
+ if expected_pages is not None and len(page_ids) != expected_pages:
639
+ raise ContractError(f"renderer produced {len(page_ids)} pages but work spec requires {expected_pages}")
640
+ artifacts = render_artifacts(job, page_ids)
641
+ artifacts[relative_job_path(job, render / "stdout.txt")] = sha256_file(render / "stdout.txt")
642
+ if sha256_file(output_html) != sha256_file(sealed / "output" / "deck.html"):
643
+ raise ContractError("writer HTML changed during rendering")
644
+ sealed_files = [sealed / "output" / "deck.html"]
645
+ sealed_files.extend(sealed / "input" / "assets" / Path(asset).name for asset in manifest["snapshots"]["assets"])
646
+ if any(not item.is_file() for item in sealed_files):
647
+ raise ContractError("sealed snapshots are incomplete")
648
+ # The renderer is a subprocess: re-read every mutable dependency before
649
+ # registering its output as current.
650
+ verify_origins_and_snapshots(job, manifest, base)
651
+ for snapshot in manifest["snapshots"]["assets"]:
652
+ sealed_asset = sealed / "input" / "assets" / Path(snapshot).name
653
+ if sha256_file(job / snapshot) != sha256_file(sealed_asset):
654
+ raise ContractError("sealed asset differs from frozen input")
655
+ manifest["render"] = {
656
+ "pages": page_ids,
657
+ "artifacts": artifacts,
658
+ "sealed": {relative_job_path(job, item): sha256_file(item) for item in sealed_files},
659
+ "reader_artifacts": [relative_job_path(job, job / "render" / "measurements.json")]
660
+ + [relative_job_path(job, job / "render" / f"page-{index:04d}.png") for index in range(1, len(page_ids) + 1)],
661
+ "renderer": {"exit_code": result.returncode, "command": command},
662
+ }
663
+ manifest["requests"]["reader"] = str(uuid.uuid4())
664
+ manifest["state"] = "rendered"
665
+ criteria = frozen_criteria(job, manifest, base)
666
+ request = reader_request(job, manifest, criteria)
667
+ write_packet(job / "reader-request.json", request, job / "reader.md", reader_markdown(request))
668
+ write_manifest(job, manifest)
669
+
670
+
671
+ def exact_request(job: Path, request_path: Path, manifest: dict[str, Any], stage: str, base: Path) -> dict[str, Any]:
672
+ expected_path = (job / f"{stage}-request.json").resolve()
673
+ if request_path.resolve() != expected_path:
674
+ raise ContractError(f"{stage} request must be this job's current request")
675
+ return assert_packet(job, manifest, stage, base)
676
+
677
+
678
+ LIFECYCLE_RECORDS = ((), ("render",), ("render", "observations"), ("render", "observations", "review"))
679
+ LIFECYCLE_STATES = ("prepared", "rendered", "observed", "submitted")
680
+ MANIFEST_BASE_FIELDS = {"protocol", "job_id", "state", "origins", "snapshots", "fingerprints", "requests"}
681
+
682
+
683
+ def validate_lifecycle(manifest: dict[str, Any]) -> str:
684
+ """Bind the stored lifecycle label to the complete registered record prefix."""
685
+ state = manifest.get("state")
686
+ if state not in LIFECYCLE_STATES:
687
+ raise ContractError("job lifecycle state is invalid")
688
+ unknown = set(manifest) - MANIFEST_BASE_FIELDS - {"render", "observations", "review"}
689
+ if unknown:
690
+ raise ContractError("manifest has unknown lifecycle fields")
691
+ registered = tuple(name for name in ("render", "observations", "review") if name in manifest)
692
+ try:
693
+ lifecycle_index = LIFECYCLE_RECORDS.index(registered)
694
+ except ValueError as exc:
695
+ raise ContractError("job lifecycle records are missing prerequisites or out of order") from exc
696
+ derived_state = LIFECYCLE_STATES[lifecycle_index]
697
+ if state != derived_state:
698
+ raise ContractError(f"job state {state} does not match registered lifecycle records ({derived_state})")
699
+ requests = require_object(manifest.get("requests"), "manifest requests")
700
+ expected_request_stages = ("writer", "reader", "judge")[: lifecycle_index + 1]
701
+ if set(requests) != set(expected_request_stages):
702
+ raise ContractError("manifest request stages do not match lifecycle state")
703
+ for stage in expected_request_stages:
704
+ require_string(requests[stage], f"manifest {stage} request ID", True)
705
+ for record in registered:
706
+ if not isinstance(manifest[record], dict):
707
+ raise ContractError(f"manifest {record} record must be an object")
708
+ return state
709
+
710
+
711
+ def preflight(job: Path, manifest: dict[str, Any], base: Path) -> None:
712
+ """Run the complete freshness chain before consuming a lifecycle stage."""
713
+ validate_lifecycle(manifest)
714
+ verify_origins_and_snapshots(job, manifest, base)
715
+ verify_render(job, manifest, base)
716
+ verify_records(job, manifest, base)
717
+
718
+
719
+ def validate_observation_payload(payload: Any, pages: list[str]) -> list[dict[str, Any]]:
720
+ obj = require_object(payload, "observation payload", {"pages"})
721
+ entries = obj["pages"]
722
+ if not isinstance(entries, list) or len(entries) != len(pages):
723
+ raise ContractError("observation payload must include every rendered page exactly once")
724
+ seen: set[str] = set()
725
+ for item in entries:
726
+ row = validate_properties(item, "observation page", "observation_page")
727
+ page_id = row["page_id"]
728
+ seen.add(page_id)
729
+ if seen != set(pages) or len(seen) != len(entries):
730
+ raise ContractError("observation page IDs must match rendered pages exactly")
731
+ return entries
732
+
733
+
734
+ def command_observe(args: argparse.Namespace) -> None:
735
+ job, base = args.job.resolve(), args.base.resolve()
736
+ manifest = load_manifest(job)
737
+ if manifest.get("state") != "rendered" or "observations" in manifest:
738
+ raise ContractError("observe is allowed once after a completed render")
739
+ preflight(job, manifest, base)
740
+ request = exact_request(job, args.request, manifest, "reader", base)
741
+ payload = read_json(args.payload.resolve(), "observation payload")
742
+ pages = validate_observation_payload(payload, manifest["render"]["pages"])
743
+ preflight(job, manifest, base)
744
+ record = {"protocol": PROTOCOL, "job_id": manifest["job_id"], "reader_request_sha256": sha256_bytes(canonical(request)), "pages": pages}
745
+ write_atomic(job / "observations.json", canonical(record))
746
+ manifest["observations"] = {"path": "observations.json", "sha256": sha256_file(job / "observations.json")}
747
+ manifest["requests"]["judge"] = str(uuid.uuid4())
748
+ manifest["state"] = "observed"
749
+ criteria = frozen_criteria(job, manifest, base)
750
+ request_judge = judge_request(job, manifest, criteria)
751
+ write_packet(job / "judge-request.json", request_judge, job / "judge.md", judge_markdown(request_judge))
752
+ write_manifest(job, manifest)
753
+
754
+
755
+ def allowed_evidence(reference: str, page_ids: list[str], source_line_count: int) -> bool:
756
+ if reference == "spec":
757
+ return True
758
+ match = re.fullmatch(r"source:L([1-9][0-9]*)", reference)
759
+ if match:
760
+ return int(match.group(1)) <= source_line_count
761
+ match = re.fullmatch(r"(?:page|measurement):(.+)", reference)
762
+ return bool(match and match.group(1) in page_ids)
763
+
764
+
765
+ def validate_submit_payload(payload: Any, criteria: list[dict[str, str]], page_ids: list[str], source_line_count: int) -> list[dict[str, Any]]:
766
+ obj = require_object(payload, "judge payload", {"items"})
767
+ items = obj["items"]
768
+ if not isinstance(items, list) or len(items) != len(criteria):
769
+ raise ContractError("judge payload must include every canonical criterion exactly once")
770
+ by_id: dict[str, dict[str, Any]] = {}
771
+ permitted = {item["id"] for item in criteria}
772
+ for item in items:
773
+ row = validate_properties(item, "judge item", "judge_item")
774
+ criterion_id = row["criterion_id"]
775
+ if criterion_id not in permitted or criterion_id in by_id:
776
+ raise ContractError("judge payload has duplicate or unknown criterion IDs")
777
+ if not isinstance(row["covered_pages"], list) or set(row["covered_pages"]) != set(page_ids) or len(row["covered_pages"]) != len(page_ids) or any(not isinstance(value, str) for value in row["covered_pages"]):
778
+ raise ContractError("covered_pages must declare every rendered page exactly once")
779
+ if not isinstance(row["evidence_refs"], list) or not row["evidence_refs"] or any(not isinstance(value, str) or not allowed_evidence(value, page_ids, source_line_count) for value in row["evidence_refs"]):
780
+ raise ContractError("evidence_refs must be nonempty supported references")
781
+ by_id[criterion_id] = row
782
+ if set(by_id) != permitted:
783
+ raise ContractError("judge payload is missing canonical criterion IDs")
784
+ return [by_id[item["id"]] for item in criteria]
785
+
786
+
787
+ def review_markdown(review: dict[str, Any]) -> str:
788
+ lines = [
789
+ "# Slide review", "",
790
+ "Structural validation confirms packet and payload shape only. It does not prove semantic quality or that an actor dispatched or read the artifacts.", "",
791
+ f"- Job: `{review['job_id']}`",
792
+ f"- Judge request SHA-256: `{review['judge_request_sha256']}`", "",
793
+ ]
794
+ for item in review["items"]:
795
+ lines.extend([
796
+ f"## {item['criterion_id']} — {item['verdict']}", "",
797
+ "### Covered pages", "", *[f"- `{page}`" for page in item["covered_pages"]], "",
798
+ "### Evidence references", "", *[f"- `{reference}`" for reference in item["evidence_refs"]], "",
799
+ "### Reason", "", item["reason"], "",
800
+ "### Proposed change", "", item["proposed_change"], "",
801
+ ])
802
+ return "\n".join(lines)
803
+
804
+
805
+ def command_submit(args: argparse.Namespace) -> None:
806
+ job, base = args.job.resolve(), args.base.resolve()
807
+ manifest = load_manifest(job)
808
+ if manifest.get("state") != "observed" or "review" in manifest:
809
+ raise ContractError("submit is allowed once after observations")
810
+ preflight(job, manifest, base)
811
+ request = exact_request(job, args.request, manifest, "judge", base)
812
+ criteria = frozen_criteria(job, manifest, base)
813
+ payload = read_json(args.payload.resolve(), "judge payload")
814
+ items = validate_submit_payload(payload, criteria, manifest["render"]["pages"], len(source_lines(job, manifest)))
815
+ preflight(job, manifest, base)
816
+ review = {"protocol": PROTOCOL, "job_id": manifest["job_id"], "judge_request_sha256": sha256_bytes(canonical(request)), "items": items}
817
+ write_atomic(job / "review.json", canonical(review))
818
+ write_atomic(job / "review.md", review_markdown(review).encode("utf-8"))
819
+ manifest["review"] = {"json": {"path": "review.json", "sha256": sha256_file(job / "review.json")}, "markdown": {"path": "review.md", "sha256": sha256_file(job / "review.md")}}
820
+ manifest["state"] = "submitted"
821
+ write_manifest(job, manifest)
822
+
823
+
824
+ def frozen_criteria(job: Path, manifest: dict[str, Any], base: Path) -> list[dict[str, str]]:
825
+ """Read the sealed guide and require its job oracle to be its exact projection."""
826
+ try:
827
+ guide_bytes = (job / manifest["snapshots"]["guide"]).read_bytes()
828
+ actual_oracle = (job / manifest["snapshots"]["oracle"]).read_bytes()
829
+ except OSError as exc:
830
+ raise ContractError("frozen criterion guide or ORACLE is missing") from exc
831
+ criteria, expected_oracle = oracle_for_guide(base, guide_bytes)
832
+ if actual_oracle != expected_oracle or sha256_bytes(actual_oracle) != manifest["fingerprints"]["oracle"]:
833
+ raise ContractError("frozen ORACLE changed")
834
+ return criteria
835
+
836
+
837
+ def verify_origins_and_snapshots(job: Path, manifest: dict[str, Any], base: Path) -> None:
838
+ if manifest.get("protocol") != PROTOCOL:
839
+ raise ContractError("job protocol is unsupported")
840
+ if manifest.get("fingerprints", {}).get("protocol") != protocol_fingerprints(base):
841
+ raise ContractError("job protocol implementation has changed")
842
+ for kind in ("source", "spec", "guide"):
843
+ origin = manifest["origins"][kind]
844
+ if sha256_file(Path(origin["path"])) != origin["sha256"]:
845
+ raise ContractError(f"origin {kind} changed after prepare")
846
+ snapshot = job / manifest["snapshots"][kind]
847
+ if sha256_file(snapshot) != origin["sha256"]:
848
+ raise ContractError(f"frozen {kind} snapshot changed")
849
+ for origin_path, detail in manifest["origins"]["assets"].items():
850
+ if sha256_file(Path(origin_path)) != detail["sha256"] or sha256_file(job / detail["path"]) != detail["sha256"]:
851
+ raise ContractError("origin asset or frozen asset changed")
852
+ frozen_criteria(job, manifest, base)
853
+
854
+
855
+ def verify_render(job: Path, manifest: dict[str, Any], base: Path) -> None:
856
+ render = manifest.get("render")
857
+ if not isinstance(render, dict):
858
+ return
859
+ renderer = require_object(render.get("renderer"), "renderer record", {"exit_code", "command"})
860
+ exit_code = renderer["exit_code"]
861
+ if isinstance(exit_code, bool) or not isinstance(exit_code, int) or exit_code != 0:
862
+ raise ContractError("renderer record must retain integer exit code 0")
863
+ command = renderer["command"]
864
+ if not isinstance(command, list) or len(command) != 5 or any(not isinstance(item, str) for item in command):
865
+ raise ContractError("renderer record command is invalid")
866
+ require_string(command[0], "renderer executable", True)
867
+ _, _, renderer_path = base_paths(base)
868
+ expected_tail = [
869
+ str(renderer_path.resolve()),
870
+ str((job / "sealed" / "output" / "deck.html").resolve()),
871
+ str((job / "render").resolve()),
872
+ str((job / "sealed").resolve()),
873
+ ]
874
+ if command[1:] != expected_tail:
875
+ raise ContractError("renderer record command does not match this job")
876
+ if validate_measurements(job / "render" / "measurements.json") != render["pages"]:
877
+ raise ContractError("rendered page IDs changed")
878
+ for path, digest in render["artifacts"].items():
879
+ if sha256_file(job / path) != digest:
880
+ raise ContractError(f"rendered artifact changed: {path}")
881
+ for path, digest in render["sealed"].items():
882
+ if sha256_file(job / path) != digest:
883
+ raise ContractError(f"sealed snapshot changed: {path}")
884
+ if sha256_file(job / "output" / "deck.html") != sha256_file(job / "sealed" / "output" / "deck.html"):
885
+ raise ContractError("writer output differs from sealed rendered HTML")
886
+
887
+
888
+ def verify_records(job: Path, manifest: dict[str, Any], base: Path) -> None:
889
+ assert_packet(job, manifest, "writer", base)
890
+ if "render" not in manifest:
891
+ return
892
+ assert_packet(job, manifest, "reader", base)
893
+ if "observations" not in manifest:
894
+ return
895
+ observations = read_json(job / "observations.json", "observations")
896
+ expected_keys = {"protocol", "job_id", "reader_request_sha256", "pages"}
897
+ require_object(observations, "observations", expected_keys)
898
+ validate_observation_payload({"pages": observations["pages"]}, manifest["render"]["pages"])
899
+ if observations["protocol"] != PROTOCOL or observations["job_id"] != manifest["job_id"]:
900
+ raise ContractError("observations context is invalid")
901
+ reader_request = assert_packet(job, manifest, "reader", base)
902
+ if observations["reader_request_sha256"] != sha256_bytes(canonical(reader_request)):
903
+ raise ContractError("observations are bound to a different reader request")
904
+ observation_record = require_object(manifest["observations"], "manifest observations", {"path", "sha256"})
905
+ if observation_record["path"] != "observations.json":
906
+ raise ContractError("observations record path is not canonical")
907
+ require_string(observation_record["sha256"], "observations record SHA-256", True)
908
+ if sha256_file(job / "observations.json") != observation_record["sha256"]:
909
+ raise ContractError("observations changed")
910
+ judge_request_value = assert_packet(job, manifest, "judge", base)
911
+ if "review" not in manifest:
912
+ return
913
+ review = read_json(job / "review.json", "review")
914
+ require_object(review, "review", {"protocol", "job_id", "judge_request_sha256", "items"})
915
+ criteria = frozen_criteria(job, manifest, base)
916
+ validate_submit_payload({"items": review["items"]}, criteria, manifest["render"]["pages"], len(source_lines(job, manifest)))
917
+ if review["protocol"] != PROTOCOL or review["job_id"] != manifest["job_id"]:
918
+ raise ContractError("review context is invalid")
919
+ if review["judge_request_sha256"] != sha256_bytes(canonical(judge_request_value)):
920
+ raise ContractError("review is bound to a different judge request")
921
+ for detail in manifest["review"].values():
922
+ if sha256_file(job / detail["path"]) != detail["sha256"]:
923
+ raise ContractError("review output changed")
924
+ if (job / "review.md").read_text(encoding="utf-8") != review_markdown(review):
925
+ raise ContractError("review markdown is not the canonical projection")
926
+
927
+
928
+ def command_verify(args: argparse.Namespace) -> None:
929
+ job, base = args.job.resolve(), args.base.resolve()
930
+ manifest = load_manifest(job)
931
+ preflight(job, manifest, base)
932
+ state = validate_lifecycle(manifest)
933
+ print(f"verified {state} job {manifest['job_id']}")
934
+
935
+
936
+ def parser() -> argparse.ArgumentParser:
937
+ default_base = Path(__file__).resolve().parent.parent
938
+ result = argparse.ArgumentParser(description=__doc__)
939
+ result.add_argument("--base", type=Path, default=default_base, help="slide-writing companion directory")
940
+ commands = result.add_subparsers(dest="command", required=True)
941
+ commands.add_parser("check")
942
+ prepare = commands.add_parser("prepare")
943
+ prepare.add_argument("--source", type=Path, required=True)
944
+ prepare.add_argument("--spec", type=Path, required=True)
945
+ prepare.add_argument("--job", type=Path, required=True)
946
+ prepare.add_argument("--asset", type=Path, action="append", default=[])
947
+ render = commands.add_parser("render")
948
+ render.add_argument("--job", type=Path, required=True)
949
+ render.add_argument("--node", type=Path, required=True)
950
+ render.add_argument("--playwright", type=Path, required=True)
951
+ render.add_argument("--browser", type=Path, required=True)
952
+ observe = commands.add_parser("observe")
953
+ observe.add_argument("--job", type=Path, required=True)
954
+ observe.add_argument("--request", type=Path, required=True)
955
+ observe.add_argument("--payload", type=Path, required=True)
956
+ submit = commands.add_parser("submit")
957
+ submit.add_argument("--job", type=Path, required=True)
958
+ submit.add_argument("--request", type=Path, required=True)
959
+ submit.add_argument("--payload", type=Path, required=True)
960
+ verify = commands.add_parser("verify")
961
+ verify.add_argument("--job", type=Path, required=True)
962
+ return result
963
+
964
+
965
+ def main(argv: list[str] | None = None) -> int:
966
+ args = parser().parse_args(argv)
967
+ try:
968
+ {"check": command_check, "prepare": command_prepare, "render": command_render, "observe": command_observe, "submit": command_submit, "verify": command_verify}[args.command](args)
969
+ except ContractError as exc:
970
+ print(f"error: {exc}", file=sys.stderr)
971
+ return 2
972
+ except (OSError, UnicodeError, KeyError, TypeError, ValueError) as exc:
973
+ print(f"error: malformed or incomplete contract data ({exc})", file=sys.stderr)
974
+ return 2
975
+ return 0
976
+
977
+
978
+ if __name__ == "__main__":
979
+ raise SystemExit(main())