agent-bios 0.12.1 → 0.13.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.
@@ -161,10 +161,19 @@ Ranked by how much the channel refuses for you:
161
161
  Stated honestly: on a prose route this is steering, not control — the fold is
162
162
  performed by an agent following this guide, and nothing structural refuses a
163
163
  class-less row for it. That is the known weaker result of a request-only rule.
164
- 3. A structural criterion slot in the review dispatch machinery is deliberately not
165
- part of this guide: a criterion is per-review while dispatch config is per-launch,
166
- and a slot with no schema-backed consumer would be an inert value. Until such a
167
- channel exists, ranks 1–2 are the real ones.
164
+ 3. **The launcher's criterion discipline** (agent-launch): a preset declaring
165
+ `criterion = true` renders one core-owned discipline clause into every review
166
+ method row the criterion's content never enters the per-launch config; it rides
167
+ the packet as a `ReviewCriterion/v1:` record line beside the prose above.
168
+ `--compile-criterion` refuses a document failing the schema's decidable subset and
169
+ emits the canonical findings schema; where the host CLI's structured-output flag
170
+ probes present (`--check-schema-flag`), the dispatch passes that schema, and
171
+ receipt emission under `REVIEW_CRITERION_SCHEMA` refuses a class-less or
172
+ out-of-enum result — no receipt, and an unproven dispatch is already in the stop
173
+ class. `--verify-receipts --packet` recompiles the packet's record and refuses a
174
+ receipt whose schema digest was compiled from any other criterion. A route whose
175
+ flag probes absent runs rank 2 and is disclosed as prose discipline — never
176
+ credited as schema-enforced.
168
177
 
169
178
  ## Golden lifecycle
170
179
 
@@ -161,10 +161,19 @@ Ranked by how much the channel refuses for you:
161
161
  Stated honestly: on a prose route this is steering, not control — the fold is
162
162
  performed by an agent following this guide, and nothing structural refuses a
163
163
  class-less row for it. That is the known weaker result of a request-only rule.
164
- 3. A structural criterion slot in the review dispatch machinery is deliberately not
165
- part of this guide: a criterion is per-review while dispatch config is per-launch,
166
- and a slot with no schema-backed consumer would be an inert value. Until such a
167
- channel exists, ranks 1–2 are the real ones.
164
+ 3. **The launcher's criterion discipline** (agent-launch): a preset declaring
165
+ `criterion = true` renders one core-owned discipline clause into every review
166
+ method row the criterion's content never enters the per-launch config; it rides
167
+ the packet as a `ReviewCriterion/v1:` record line beside the prose above.
168
+ `--compile-criterion` refuses a document failing the schema's decidable subset and
169
+ emits the canonical findings schema; where the host CLI's structured-output flag
170
+ probes present (`--check-schema-flag`), the dispatch passes that schema, and
171
+ receipt emission under `REVIEW_CRITERION_SCHEMA` refuses a class-less or
172
+ out-of-enum result — no receipt, and an unproven dispatch is already in the stop
173
+ class. `--verify-receipts --packet` recompiles the packet's record and refuses a
174
+ receipt whose schema digest was compiled from any other criterion. A route whose
175
+ flag probes absent runs rank 2 and is disclosed as prose discipline — never
176
+ credited as schema-enforced.
168
177
 
169
178
  ## Golden lifecycle
170
179
 
@@ -937,6 +937,19 @@ SEVERITY_NAME_RULE = (
937
937
  # forbidding the labels that trigger it, which is why `<`, `-` and `=` stay registrable.
938
938
  SEVERITY_ARROW = " => "
939
939
  SEVERITY_CLAUSE_MARKER = "; severities "
940
+ # The criterion discipline clause. A CONSTANT, not a slot: the criterion's content is
941
+ # per-review and lives in the packet (which packet_sha256 binds), while the contract is
942
+ # per-launch and golden-pinned — so what renders here is only the discipline, identical
943
+ # for every method and every criterion. Placed BEFORE the severity clause in the row,
944
+ # because the severity clause is parsed as everything after its marker.
945
+ CRITERION_CLAUSE_MARKER = "; criterion "
946
+ CRITERION_CLAUSE = (
947
+ f"{CRITERION_CLAUSE_MARKER}declared in the packet: compile it with "
948
+ f"--compile-criterion, prefer a dispatch whose host schema flag probed present "
949
+ f"(--check-schema-flag), emit receipts under REVIEW_CRITERION_SCHEMA so emission "
950
+ f"refuses unclassified findings, and fold returned rows against the declared enum "
951
+ f"without guessing a class"
952
+ )
940
953
  # The panel that carries corpus/registration text, and the scrolling body that holds it.
941
954
  # Named once so the screen, its CSS, its key bindings and the check that drives them cannot
942
955
  # drift apart.
@@ -1127,6 +1140,17 @@ class ReviewMechanism:
1127
1140
  def parse_review_method(method_id: str, raw: Any, context: str) -> ReviewMethod:
1128
1141
  if not isinstance(raw, dict):
1129
1142
  raise LaunchError(f"{context} must be a table")
1143
+ # The method ID is the one authored value core PREFIXES to the rendered row, so the
1144
+ # formatted-body marker checks never see it — a registry key carrying a clause
1145
+ # marker forged a second decodable clause beside core's (criterion round, #1; the
1146
+ # same door round 20 #5 opened for the plan marker, closed there on the whole row).
1147
+ for marker in (SEVERITY_CLAUSE_MARKER, CONTROLS_CLAUSE_MARKER, CRITERION_CLAUSE_MARKER):
1148
+ if marker in method_id:
1149
+ raise LaunchError(
1150
+ f"{context}: the method id contains {marker!r}, which core owns for a "
1151
+ f"rendered clause; an id carrying it would forge a second clause in "
1152
+ f"every row it prefixes"
1153
+ )
1130
1154
  unknown = sorted(set(raw) - METHOD_KEYS)
1131
1155
  if unknown:
1132
1156
  raise LaunchError(f"{context}: unknown method key(s): {', '.join(unknown)}")
@@ -1307,6 +1331,12 @@ def validate_instruction_slots(template: str, context: str) -> set:
1307
1331
  f"{context}: contains {CONTROLS_CLAUSE_MARKER!r}, which core appends from the "
1308
1332
  f"method's declared controls; a second clause would contradict it"
1309
1333
  )
1334
+ if CRITERION_CLAUSE_MARKER in template:
1335
+ raise LaunchError(
1336
+ f"{context}: contains {CRITERION_CLAUSE_MARKER!r}, which core appends when the "
1337
+ f"plan declares a criterion-disciplined review; a second clause would "
1338
+ f"contradict the plan's toggle"
1339
+ )
1310
1340
  refuse_plan_marker(template, context)
1311
1341
  return used
1312
1342
 
@@ -1513,9 +1543,16 @@ def derive_review_mechanism(
1513
1543
  )
1514
1544
 
1515
1545
 
1516
- def render_review_method(method: ReviewMethod, mechanism: ReviewMechanism) -> str:
1546
+ def render_review_method(
1547
+ method: ReviewMethod, mechanism: ReviewMechanism, criterion: bool = False
1548
+ ) -> str:
1517
1549
  """The generic renderer. Every method — shipped or third-party — reaches the
1518
- contract through this one function, with the exact seat already resolved."""
1550
+ contract through this one function, with the exact seat already resolved.
1551
+
1552
+ `criterion` is the plan's toggle, threaded rather than read from anywhere global:
1553
+ when the preset declares a criterion-disciplined review, core appends the one
1554
+ discipline clause to every row, method-blind, the way the severity translation is
1555
+ appended — an author never writes it and a slot never carries it."""
1519
1556
  binding = mechanism.binding
1520
1557
  slots = {
1521
1558
  "command": mechanism.command or "",
@@ -1561,7 +1598,8 @@ def render_review_method(method: ReviewMethod, mechanism: ReviewMechanism) -> st
1561
1598
  # them as literals, and a perspective label carrying `; controls trials=1` rendered a
1562
1599
  # second controls clause beside core's (round 19, #9). Every substitution passes here.
1563
1600
  for marker, owner in ((CONTROLS_CLAUSE_MARKER, "the method's declared controls"),
1564
- (REVIEW_PLAN_MARKER, "the contract's canonical review record")):
1601
+ (REVIEW_PLAN_MARKER, "the contract's canonical review record"),
1602
+ (CRITERION_CLAUSE_MARKER, "the plan's criterion discipline")):
1565
1603
  if marker in body:
1566
1604
  raise LaunchError(
1567
1605
  f"review method {method.method_id!r} renders {marker!r} in its body, which "
@@ -1570,7 +1608,7 @@ def render_review_method(method: ReviewMethod, mechanism: ReviewMechanism) -> st
1570
1608
  rendered = (
1571
1609
  f"{method.method_id}: {body} "
1572
1610
  f"[{mechanism.shape}; {binding.model}/{binding.effort}"
1573
- f"{controls_clause(method)}{severity_translation(method)}]"
1611
+ f"{controls_clause(method)}{criterion_clause(criterion)}{severity_translation(method)}]"
1574
1612
  )
1575
1613
  # The assembled ROW, after the body. The body check above catches a SLOT value and says
1576
1614
  # so; what it cannot see is the part core itself prefixes — the METHOD ID, a user-chosen
@@ -1623,6 +1661,16 @@ def severity_vocabulary(method: ReviewMethod) -> str:
1623
1661
  return ", ".join(method.severity_emits)
1624
1662
 
1625
1663
 
1664
+ def criterion_clause(active: bool) -> str:
1665
+ """The discipline clause, or "" when the plan declares no criterion.
1666
+
1667
+ Core-owned and constant for the reason severity_translation is core-appended: a
1668
+ third-party method carries it without its author remembering to, and the gate can
1669
+ subtract the exact substring. Inside the seat bracket, BEFORE the severity clause,
1670
+ which is parsed as everything after its own marker."""
1671
+ return CRITERION_CLAUSE if active else ""
1672
+
1673
+
1626
1674
  def severity_translation(method: ReviewMethod) -> str:
1627
1675
  """The clause telling the reader how to move this reviewer's severities onto the
1628
1676
  canonical ladder, or "" when it reports on the ladder already.
@@ -1743,7 +1791,8 @@ def independence_grade(reviewer: ReviewBinding, main: ReviewBinding) -> str:
1743
1791
 
1744
1792
 
1745
1793
  def _resolve_one(
1746
- method: ReviewMethod, binding: ReviewBinding, main: ReviewBinding, config: dict[str, Any]
1794
+ method: ReviewMethod, binding: ReviewBinding, main: ReviewBinding,
1795
+ config: dict[str, Any], criterion: bool = False,
1747
1796
  ) -> ReviewMethodReport:
1748
1797
  """One method against the main seat. An optional method is NEVER silently rebound
1749
1798
  to a different seat: if its mechanism cannot be derived it is dropped, and the
@@ -1823,7 +1872,7 @@ def _resolve_one(
1823
1872
  return ReviewMethodReport(
1824
1873
  method.method_id, status, grade, "; ".join(notes),
1825
1874
  binding.model, binding.effort, binding.provider, mechanism.adapter,
1826
- render_review_method(method, mechanism), method_controls(method),
1875
+ render_review_method(method, mechanism, criterion), method_controls(method),
1827
1876
  tuple(mechanism.evidence),
1828
1877
  )
1829
1878
 
@@ -1844,7 +1893,7 @@ def best_review_grade(rows) -> str:
1844
1893
 
1845
1894
  def resolve_composable_review(
1846
1895
  review: ReviewPlan, main: ReviewBinding, config: dict[str, Any],
1847
- methods: dict[str, ReviewMethod],
1896
+ methods: dict[str, ReviewMethod], criterion: bool = False,
1848
1897
  ) -> ReviewReport:
1849
1898
  """Base panel plus the method map, each graded against the main seat.
1850
1899
 
@@ -1858,7 +1907,7 @@ def resolve_composable_review(
1858
1907
  if panel is None:
1859
1908
  raise LaunchError(f"the {PANEL_METHOD} method is required and is not registered")
1860
1909
  base_binding = review.base_binding or main
1861
- base = _resolve_one(panel, base_binding, main, config)
1910
+ base = _resolve_one(panel, base_binding, main, config, criterion)
1862
1911
  # Authored-but-unsatisfiable is a fallback too, not just an undeliverable mechanism.
1863
1912
  # Without this the §1 rule below was unreachable for the commonest case — a preset
1864
1913
  # naming a provider the profile has no host for never got here at all, because the
@@ -1866,7 +1915,7 @@ def resolve_composable_review(
1866
1915
  fell_back = review.base_unseated is not None
1867
1916
  if base.status == STATUS_DROPPED and review.base_binding is not None:
1868
1917
  # Try the same floor on the main's own seat before giving up on review.
1869
- base = _resolve_one(panel, main, main, config)
1918
+ base = _resolve_one(panel, main, main, config, criterion)
1870
1919
  fell_back = True
1871
1920
  if fell_back and base.status != STATUS_DROPPED:
1872
1921
  detail = (
@@ -1905,7 +1954,7 @@ def resolve_composable_review(
1905
1954
  if unseated
1906
1955
  else "no binding: the method names no verifier seat"))
1907
1956
  continue
1908
- rows.append(_resolve_one(method, binding, main, config))
1957
+ rows.append(_resolve_one(method, binding, main, config, criterion))
1909
1958
  return ReviewReport(base, tuple(rows), best_review_grade((base, *rows)))
1910
1959
 
1911
1960
 
@@ -2375,6 +2424,12 @@ RECEIPT_KEYS = frozenset({
2375
2424
  "provider", "model", "effort", "exit_status", "passes", "ordering_seed", "swap_group",
2376
2425
  # What the tool reported back, keyed by the field names its offer declared.
2377
2426
  "evidence",
2427
+ # The digest of the compiled criterion schema the emission validated this result
2428
+ # against — present ONLY on a schema-route dispatch (REVIEW_CRITERION_SCHEMA was
2429
+ # set), read by --verify-receipts, which recompiles the packet's criterion and
2430
+ # refuses a mismatch. Absent on a prose-route dispatch, which is disclosed, never
2431
+ # refused: host schema capability is per-machine and not retroactively provable.
2432
+ "criterion_schema_sha256",
2378
2433
  })
2379
2434
  # The BUNDLE's grammar, closed for the reason the receipt's is. The reader took its four
2380
2435
  # anchors and ignored everything else, so a key it does not know rode along unjudged and
@@ -2460,6 +2515,16 @@ def _receipt_structure_reason(receipt: Any) -> str:
2460
2515
  f"records exit_status as {type(status).__name__}, not an integer — a boolean is "
2461
2516
  f"not an exit status, and JSON false is not exit 0"
2462
2517
  )
2518
+ # Form here, in the door the adjudicator AND the fold share, so a folded pair of
2519
+ # matching non-hashes cannot agree its way past the per-receipt check.
2520
+ if "criterion_schema_sha256" in receipt and not is_sha256_hex(
2521
+ receipt["criterion_schema_sha256"]
2522
+ ):
2523
+ return (
2524
+ f"records criterion_schema_sha256={receipt['criterion_schema_sha256']!r}, "
2525
+ f"which is not a SHA-256 hash — nothing was hashed, so no schema validated "
2526
+ f"this result"
2527
+ )
2463
2528
  return ""
2464
2529
 
2465
2530
 
@@ -2496,7 +2561,7 @@ def _raw_receipt_reason(receipt: Any) -> str:
2496
2561
 
2497
2562
  def _receipt_reason(
2498
2563
  receipt: Any, row: "ReviewMethodReport | None", bundle: dict, seen: set,
2499
- controls: dict, required_evidence: tuple = (),
2564
+ controls: dict, required_evidence: tuple = (), criterion_digest: "str | None" = None,
2500
2565
  ) -> str:
2501
2566
  """Why this receipt cannot be credited, or "" when it can.
2502
2567
 
@@ -2545,6 +2610,22 @@ def _receipt_reason(
2545
2610
  )
2546
2611
  if packet != bundle.get("packet_sha256"):
2547
2612
  return "hashes a different packet than the one the plan declared"
2613
+ # Only when the packet's criterion is in hand: the digest is recompiled from the
2614
+ # packet's own record, so a mismatch means the schema the host was handed was
2615
+ # compiled from some OTHER criterion — a stricter or looser enum than the one this
2616
+ # packet declares. With no packet supplied there is nothing to recompile, and the
2617
+ # rendering says the claim is unbound rather than treating it as proven.
2618
+ if (
2619
+ criterion_digest is not None
2620
+ and "criterion_schema_sha256" in receipt
2621
+ and receipt["criterion_schema_sha256"] != criterion_digest
2622
+ ):
2623
+ return (
2624
+ f"was validated against a schema hashing "
2625
+ f"{receipt['criterion_schema_sha256']}, and recompiling this packet's "
2626
+ f"declared criterion yields {criterion_digest} — the schema enforced was "
2627
+ f"not compiled from the criterion this packet declares"
2628
+ )
2548
2629
  for field in ("provider", "model", "effort"):
2549
2630
  if receipt.get(field) != getattr(row, field):
2550
2631
  return (
@@ -2639,7 +2720,7 @@ def _receipt_reason(
2639
2720
 
2640
2721
  def verify_review_receipts(
2641
2722
  report: ReviewReport, bundle: Any, required_controls: dict[str, dict],
2642
- required_evidence: dict[str, tuple],
2723
+ required_evidence: dict[str, tuple], criterion_digest: "str | None" = None,
2643
2724
  ) -> tuple[ReviewReport, tuple[ReceiptVerdict, ...]]:
2644
2725
  """Adjudicate a review against its receipts.
2645
2726
 
@@ -2860,6 +2941,7 @@ def verify_review_receipts(
2860
2941
  # The row's own snapshot, never the caller's recomputation — see the drift
2861
2942
  # refusal above.
2862
2943
  row.evidence if row else (),
2944
+ criterion_digest,
2863
2945
  )
2864
2946
  if not reason and isinstance(receipt.get("dispatch_id"), str):
2865
2947
  seen.add(receipt["dispatch_id"])
@@ -2908,7 +2990,8 @@ def verify_review_receipts(
2908
2990
 
2909
2991
 
2910
2992
  def render_receipt_verdicts(
2911
- report: ReviewReport, verdicts: tuple[ReceiptVerdict, ...], packet_bound: bool = False
2993
+ report: ReviewReport, verdicts: tuple[ReceiptVerdict, ...],
2994
+ packet_bound: bool = False, criterion_note: str = "",
2912
2995
  ) -> str:
2913
2996
  # Coverage is printed as a count, not only as a word: `partial` tells a reader the set
2914
2997
  # was incomplete and `1/3` tells them how incomplete, which is the difference between
@@ -2952,6 +3035,10 @@ def render_receipt_verdicts(
2952
3035
  " grade_derivation=claimed — the plan does not serialize the main seat, so a row's "
2953
3036
  "independence grade is the launch's own claim and is not recomputed here"
2954
3037
  )
3038
+ if criterion_note:
3039
+ # Empty on every non-criterion adjudication, so the rendering is byte-identical
3040
+ # to today's whenever the plan declared no discipline.
3041
+ lines.append(criterion_note)
2955
3042
  for verdict in verdicts:
2956
3043
  state = "ACHIEVED" if verdict.accepted else "PROPOSED"
2957
3044
  lines.append(
@@ -2962,6 +3049,426 @@ def render_receipt_verdicts(
2962
3049
  return "\n".join(lines)
2963
3050
 
2964
3051
 
3052
+ # ── criterion discipline (defect-criterion stage 6) ─────────────────────────
3053
+ # The criterion is PER-REVIEW: its content rides the packet, never the contract or the
3054
+ # config. What core owns is the deterministic subset — the document grammar, the
3055
+ # compiled findings schema, the packet record line, and the findings check that receipt
3056
+ # emission runs. Whether a golden is persuasive or a stop condition genuinely reachable
3057
+ # stays semantic and is never adjudicated here.
3058
+ CRITERION_RECORD_SCHEMA = "ReviewCriterion/v1"
3059
+ CRITERION_RECORD_MARKER = f"{CRITERION_RECORD_SCHEMA}: "
3060
+ # The guide's eight fields plus the two the machine subset needs named apart: the enum
3061
+ # and its one stop-relevant member. Closed, and every key required — the guide's own
3062
+ # rule is that an empty cell makes a hunch, not a criterion.
3063
+ CRITERION_KEYS = frozenset({
3064
+ "name", "observer", "defect", "classes", "stop_class", "evidence",
3065
+ "non_defects", "stop_condition", "misclassification_cost", "goldens",
3066
+ })
3067
+ CRITERION_GOLDEN_KEYS = frozenset({"kind", "case", "why", "date", "provenance"})
3068
+ CRITERION_GOLDEN_KINDS = ("positive", "negative", "boundary")
3069
+ # The guide's admission bar: ≥2 positive, ≥2 negative, ≥1 boundary.
3070
+ CRITERION_GOLDEN_MINIMUMS = {"positive": 2, "negative": 2, "boundary": 1}
3071
+ CRITERION_PROVENANCE = frozenset({"measured", "constructed"})
3072
+
3073
+
3074
+ def _utf8_encodable(value: str) -> bool:
3075
+ """False for a string no UTF-8 artifact can carry — a JSON escape can smuggle a
3076
+ lone surrogate through json.loads, and every encode after it crashes."""
3077
+ try:
3078
+ value.encode("utf-8")
3079
+ except UnicodeEncodeError:
3080
+ return False
3081
+ return True
3082
+
3083
+
3084
+ def validate_criterion(document: Any, context: str) -> None:
3085
+ """Refuse a criterion document that fails the DECIDABLE subset of the guide schema,
3086
+ by field name. Nothing downstream receives a half-criterion: there is no fallback
3087
+ to "no criterion" and no default for any cell."""
3088
+ if not isinstance(document, dict):
3089
+ raise LaunchError(f"{context} must be a JSON object")
3090
+ # The whole document must be UTF-8-encodable BEFORE any field check: JSON escapes
3091
+ # admit lone surrogates that json.loads accepts and every later encode —
3092
+ # the canonical record line, the compiled schema bytes — crashes on with a
3093
+ # traceback instead of a named refusal (criterion round 2, #0).
3094
+ try:
3095
+ json.dumps(document, ensure_ascii=False).encode("utf-8")
3096
+ except UnicodeEncodeError as exc:
3097
+ raise LaunchError(
3098
+ f"{context} is not UTF-8-encodable ({exc}); a lone surrogate cannot ride "
3099
+ f"the record line or the compiled schema"
3100
+ ) from exc
3101
+ missing = sorted(CRITERION_KEYS - set(document))
3102
+ if missing:
3103
+ raise LaunchError(
3104
+ f"{context} is missing {', '.join(missing)}; an empty cell makes a hunch, "
3105
+ f"not a criterion"
3106
+ )
3107
+ unknown = sorted(set(document) - CRITERION_KEYS)
3108
+ if unknown:
3109
+ raise LaunchError(f"{context} carries unknown key(s): {', '.join(unknown)}")
3110
+ for field in ("name", "observer", "defect", "evidence", "stop_condition",
3111
+ "misclassification_cost"):
3112
+ value = document[field]
3113
+ if not isinstance(value, str) or not value.strip():
3114
+ raise LaunchError(f"{context}.{field} must be a non-empty string")
3115
+ classes = document["classes"]
3116
+ if (
3117
+ not isinstance(classes, list) or len(classes) < 2
3118
+ or not all(isinstance(value, str) and value for value in classes)
3119
+ ):
3120
+ raise LaunchError(
3121
+ f"{context}.classes must list at least two non-empty class names — the "
3122
+ f"stop-relevant class and the relief valves that keep it honest"
3123
+ )
3124
+ if len(set(classes)) != len(classes):
3125
+ raise LaunchError(f"{context}.classes repeats a value")
3126
+ for value in classes:
3127
+ if value != value.strip():
3128
+ raise LaunchError(
3129
+ f"{context}.classes[{value!r}] may not carry leading or trailing "
3130
+ f"whitespace, which no reader can see"
3131
+ )
3132
+ stop = document["stop_class"]
3133
+ if not isinstance(stop, str) or stop not in classes:
3134
+ raise LaunchError(
3135
+ f"{context}.stop_class must name exactly one member of classes; "
3136
+ f"got {stop!r}"
3137
+ )
3138
+ non_defects = document["non_defects"]
3139
+ if (
3140
+ not isinstance(non_defects, list) or not non_defects
3141
+ or not all(isinstance(value, str) and value.strip() for value in non_defects)
3142
+ ):
3143
+ raise LaunchError(
3144
+ f"{context}.non_defects must be a non-empty list of the defect-lookalikes "
3145
+ f"this criterion excludes"
3146
+ )
3147
+ goldens = document["goldens"]
3148
+ if not isinstance(goldens, list):
3149
+ raise LaunchError(f"{context}.goldens must be a list")
3150
+ counts = {kind: 0 for kind in CRITERION_GOLDEN_KINDS}
3151
+ for index, golden in enumerate(goldens):
3152
+ slot = f"{context}.goldens[{index}]"
3153
+ if not isinstance(golden, dict):
3154
+ raise LaunchError(f"{slot} must be an object")
3155
+ missing = sorted(CRITERION_GOLDEN_KEYS - set(golden))
3156
+ if missing:
3157
+ raise LaunchError(f"{slot} is missing {', '.join(missing)}")
3158
+ unknown = sorted(set(golden) - CRITERION_GOLDEN_KEYS)
3159
+ if unknown:
3160
+ raise LaunchError(f"{slot} carries unknown key(s): {', '.join(unknown)}")
3161
+ kind = golden["kind"]
3162
+ if kind not in CRITERION_GOLDEN_KINDS:
3163
+ raise LaunchError(
3164
+ f"{slot}.kind must be one of {', '.join(CRITERION_GOLDEN_KINDS)}; "
3165
+ f"got {kind!r}"
3166
+ )
3167
+ for field in ("case", "why"):
3168
+ if not isinstance(golden[field], str) or not golden[field].strip():
3169
+ raise LaunchError(f"{slot}.{field} must be a non-empty string")
3170
+ date = golden["date"]
3171
+ if not isinstance(date, str):
3172
+ raise LaunchError(f"{slot}.date must be a string")
3173
+ try:
3174
+ datetime.date.fromisoformat(date)
3175
+ except ValueError as exc:
3176
+ raise LaunchError(f"{slot}.date must be an ISO date: {exc}") from exc
3177
+ if golden["provenance"] not in CRITERION_PROVENANCE:
3178
+ raise LaunchError(
3179
+ f"{slot}.provenance must be measured or constructed; "
3180
+ f"got {golden['provenance']!r}"
3181
+ )
3182
+ counts[kind] += 1
3183
+ short = [
3184
+ f"{kind} {counts[kind]}/{minimum}"
3185
+ for kind, minimum in CRITERION_GOLDEN_MINIMUMS.items()
3186
+ if counts[kind] < minimum
3187
+ ]
3188
+ if short:
3189
+ raise LaunchError(
3190
+ f"{context}.goldens falls short of the admission bar ({', '.join(short)}); "
3191
+ f"the bar is never weakened to admit an entry"
3192
+ )
3193
+
3194
+
3195
+ def criterion_schema(classes: list) -> dict:
3196
+ """The compiled findings schema — a pure function of the enum, which is what makes
3197
+ `--verify-receipts` able to recompile it from the packet's record and compare
3198
+ digests. Both object levels reject unknown fields; `findings` may be empty."""
3199
+ return {
3200
+ "type": "object",
3201
+ "additionalProperties": False,
3202
+ "required": ["findings"],
3203
+ "properties": {
3204
+ "findings": {
3205
+ "type": "array",
3206
+ "items": {
3207
+ "type": "object",
3208
+ "additionalProperties": False,
3209
+ "required": ["class", "finding"],
3210
+ "properties": {
3211
+ "class": {"enum": list(classes)},
3212
+ "finding": {"type": "string", "minLength": 1},
3213
+ },
3214
+ },
3215
+ },
3216
+ },
3217
+ }
3218
+
3219
+
3220
+ def criterion_schema_bytes(document: dict) -> bytes:
3221
+ """Canonical bytes for the compiled schema: sorted keys, no whitespace, one
3222
+ trailing newline. Byte-identical on every compile of the same document — proven by
3223
+ the double-compile control — because the receipt digest is compared against a
3224
+ recompilation."""
3225
+ schema = criterion_schema(document["classes"])
3226
+ return (
3227
+ json.dumps(schema, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
3228
+ + "\n"
3229
+ ).encode("utf-8")
3230
+
3231
+
3232
+ def criterion_record_line(document: dict) -> str:
3233
+ """The one line the packet carries: the canonical serialization of the document
3234
+ behind the record marker, the same shape the ReviewPlan/v1 record rides in."""
3235
+ return CRITERION_RECORD_MARKER + json.dumps(
3236
+ document, ensure_ascii=False, sort_keys=True, separators=(",", ":")
3237
+ )
3238
+
3239
+
3240
+ def extract_criterion(text: str, context: str) -> dict:
3241
+ """The criterion the packet declares, from its record line — exactly one.
3242
+
3243
+ Zero records is a refusal rather than None on purpose: this is only called when
3244
+ the plan's rows carry the discipline clause, and a launch that declared the
3245
+ discipline cannot verify without the criterion."""
3246
+ # LINE-anchored, because the packet is free text: an unanchored find took a prose
3247
+ # mention — "for example, ReviewCriterion/v1: {...}" mid-sentence — as the declared
3248
+ # record (criterion round, #5). The record is a line the compiler printed: marker at
3249
+ # line start, one JSON value, nothing else before the newline.
3250
+ starts = []
3251
+ index = text.find(CRITERION_RECORD_MARKER)
3252
+ while index >= 0:
3253
+ if index == 0 or text[index - 1] == "\n":
3254
+ starts.append(index)
3255
+ index = text.find(CRITERION_RECORD_MARKER, index + 1)
3256
+ if not starts:
3257
+ raise LaunchError(
3258
+ f"{context} carries no {CRITERION_RECORD_SCHEMA} record line, so the "
3259
+ f"criterion the plan's discipline clause declares is nowhere in the bytes "
3260
+ f"the reviewers consumed"
3261
+ )
3262
+ if len(starts) > 1:
3263
+ raise LaunchError(
3264
+ f"{context} carries more than one {CRITERION_RECORD_SCHEMA} record; a mixed "
3265
+ f"packet is split into one packet per criterion, so exactly one is the claim "
3266
+ f"this can adjudicate"
3267
+ )
3268
+ start = starts[0] + len(CRITERION_RECORD_MARKER)
3269
+ try:
3270
+ document, consumed = json.JSONDecoder().raw_decode(text[start:])
3271
+ except json.JSONDecodeError as exc:
3272
+ raise LaunchError(
3273
+ f"{context} carries a {CRITERION_RECORD_SCHEMA} record that does not parse: "
3274
+ f"{exc}"
3275
+ ) from exc
3276
+ trailing = text[start + consumed:].split("\n", 1)[0]
3277
+ if trailing.strip():
3278
+ raise LaunchError(
3279
+ f"{context} carries a {CRITERION_RECORD_SCHEMA} record line with trailing "
3280
+ f"content ({trailing.strip()[:40]!r}); the record is the whole line, so "
3281
+ f"extra text on it is a different claim than the compiler printed"
3282
+ )
3283
+ validate_criterion(document, f"{context}'s {CRITERION_RECORD_SCHEMA} record")
3284
+ return document
3285
+
3286
+
3287
+ def criterion_classes_of_schema(schema: Any, context: str) -> list:
3288
+ """The enum a compiled schema file carries — accepted only when the whole schema
3289
+ equals what compiling that enum produces, so a hand-edited or foreign schema is
3290
+ refused rather than partially read."""
3291
+ try:
3292
+ classes = schema["properties"]["findings"]["items"]["properties"]["class"]["enum"]
3293
+ except (TypeError, KeyError):
3294
+ raise LaunchError(
3295
+ f"{context} is not a compiled criterion schema; compile one with "
3296
+ f"--compile-criterion"
3297
+ ) from None
3298
+ # Self-consistency alone lets the schema supply its own authority: an exact
3299
+ # criterion_schema(['anything']) equals recompiling its own enum although the
3300
+ # compiler can never produce it — validate_criterion refuses every one-class
3301
+ # document (criterion round, #2). The enum must also be one the compiler admits.
3302
+ if (
3303
+ not isinstance(classes, list) or len(classes) < 2
3304
+ or len(set(classes)) != len(classes)
3305
+ or not all(
3306
+ isinstance(value, str) and value and value == value.strip()
3307
+ and _utf8_encodable(value)
3308
+ for value in classes
3309
+ )
3310
+ ):
3311
+ raise LaunchError(
3312
+ f"{context} carries an enum --compile-criterion can never produce (fewer "
3313
+ f"than two classes, or a duplicate, empty, whitespace-padded, or "
3314
+ f"non-UTF-8-encodable name); it was not written by the compiler — "
3315
+ f"recompile rather than editing the artifact"
3316
+ )
3317
+ if schema != criterion_schema(classes):
3318
+ raise LaunchError(
3319
+ f"{context} differs from what compiling its own enum produces, so it was "
3320
+ f"not written by --compile-criterion; recompile rather than editing the "
3321
+ f"artifact"
3322
+ )
3323
+ return list(classes)
3324
+
3325
+
3326
+ def findings_violations(result: Any, classes: list) -> list:
3327
+ """Why this result is not a findings record under the declared enum — every
3328
+ violation named, empty when it conforms. Deterministic and total: no salvage, no
3329
+ guessed class, no partial admission."""
3330
+ violations = []
3331
+ if not isinstance(result, dict):
3332
+ return [f"the result is {type(result).__name__}, not an object"]
3333
+ unknown = sorted(set(result) - {"findings"})
3334
+ if unknown:
3335
+ violations.append(f"unknown result key(s): {', '.join(unknown)}")
3336
+ findings = result.get("findings")
3337
+ if not isinstance(findings, list):
3338
+ violations.append("the result carries no findings array")
3339
+ return violations
3340
+ for index, finding in enumerate(findings):
3341
+ slot = f"findings[{index}]"
3342
+ if not isinstance(finding, dict):
3343
+ violations.append(f"{slot} is {type(finding).__name__}, not an object")
3344
+ continue
3345
+ unknown = sorted(set(finding) - {"class", "finding"})
3346
+ if unknown:
3347
+ violations.append(f"{slot} carries unknown key(s): {', '.join(unknown)}")
3348
+ if "class" not in finding:
3349
+ violations.append(f"{slot} carries no class; a row is never admitted unclassified")
3350
+ elif finding["class"] not in classes:
3351
+ violations.append(
3352
+ f"{slot} carries class {finding['class']!r}, which is not in the "
3353
+ f"declared enum ({', '.join(classes)})"
3354
+ )
3355
+ prose = finding.get("finding")
3356
+ if not isinstance(prose, str) or not prose.strip():
3357
+ violations.append(f"{slot}.finding must be a non-empty string")
3358
+ return violations
3359
+
3360
+
3361
+ def compile_criterion_command(criterion_file: str, schema_out: str) -> int:
3362
+ """Validate a criterion document and publish its compiled findings schema.
3363
+
3364
+ Prints the packet record line the dispatching agent pastes verbatim, and the
3365
+ digest receipt emission will stamp — so the three artifacts (packet record,
3366
+ schema file, receipt digest) cannot drift from one authoring."""
3367
+ path = pathlib.Path(criterion_file).expanduser()
3368
+ try:
3369
+ document = json.loads(path.read_text(encoding="utf-8"))
3370
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
3371
+ raise LaunchError(f"cannot read criterion document {path}: {exc}") from exc
3372
+ validate_criterion(document, str(path))
3373
+ payload = criterion_schema_bytes(document)
3374
+ target = pathlib.Path(schema_out).expanduser()
3375
+ try:
3376
+ target.parent.mkdir(parents=True, exist_ok=True)
3377
+ publish_atomically(target, payload)
3378
+ except OSError as exc:
3379
+ raise LaunchError(f"cannot write compiled schema into {target}: {exc}") from exc
3380
+ print(criterion_record_line(document))
3381
+ print(f"criterion_schema_sha256={hashlib.sha256(payload).hexdigest()} {target}")
3382
+ return 0
3383
+
3384
+
3385
+ def check_findings_command(result_file: str, schema_file: str) -> int:
3386
+ """The structural accepting channel, standalone: refuse a findings result that
3387
+ fails the compiled schema, each violation by name. The same check runs inside
3388
+ --emit-receipt when REVIEW_CRITERION_SCHEMA is set, so bypassing this one only
3389
+ forfeits the receipt."""
3390
+ schema_path = pathlib.Path(schema_file).expanduser()
3391
+ try:
3392
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
3393
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
3394
+ raise LaunchError(f"cannot read compiled schema {schema_path}: {exc}") from exc
3395
+ classes = criterion_classes_of_schema(schema, str(schema_path))
3396
+ result_path = pathlib.Path(result_file).expanduser()
3397
+ try:
3398
+ result = json.loads(result_path.read_text(encoding="utf-8"))
3399
+ except (OSError, UnicodeDecodeError) as exc:
3400
+ raise LaunchError(f"cannot read result {result_path}: {exc}") from exc
3401
+ except json.JSONDecodeError as exc:
3402
+ print(f"REFUSED: {result_path} is not JSON, so no finding in it carries a class: {exc}")
3403
+ return 1
3404
+ violations = findings_violations(result, classes)
3405
+ if violations:
3406
+ for violation in violations:
3407
+ print(f"REFUSED: {violation}")
3408
+ return 1
3409
+ count = len(result["findings"])
3410
+ print(f"OK: {count} finding{'s' if count != 1 else ''} conform to the declared enum")
3411
+ return 0
3412
+
3413
+
3414
+ # The host CLIs' structured-output flags, and how each registers them. A TABLE plus a
3415
+ # PROBE rather than an offer key: a declared capability is documentation, and the rule
3416
+ # is probe-over-docs — the flag either appears in the installed binary's registered
3417
+ # options or the route renders prose discipline.
3418
+ HOST_SCHEMA_FLAGS = {
3419
+ "codex": (("exec", "--help"), "--output-schema"),
3420
+ "claude": (("--help",), "--json-schema"),
3421
+ }
3422
+ SCHEMA_FLAG_ABSENT_EXIT = 4
3423
+
3424
+
3425
+ def check_schema_flag_command(host: str, config_path: pathlib.Path) -> int:
3426
+ """Probe the resolved backend binary for its structured-output flag.
3427
+
3428
+ Exit 0 when the flag is registered, SCHEMA_FLAG_ABSENT_EXIT when the binary runs
3429
+ and does not register it — absence is a finding, not an error — and a LaunchError
3430
+ when nothing could be probed at all."""
3431
+ if host not in HOST_SCHEMA_FLAGS:
3432
+ raise LaunchError(
3433
+ f"no schema flag is known for host {host!r}; known hosts: "
3434
+ f"{', '.join(sorted(HOST_SCHEMA_FLAGS))}"
3435
+ )
3436
+ config = load_config(config_path)
3437
+ command = config.get("backends", {}).get(host, {}).get("command")
3438
+ if not isinstance(command, str) or not command:
3439
+ raise LaunchError(f"config names no backend command for host {host!r}")
3440
+ help_args, flag = HOST_SCHEMA_FLAGS[host]
3441
+ try:
3442
+ probe = subprocess.run(
3443
+ [command, *help_args], capture_output=True, text=True, timeout=60,
3444
+ )
3445
+ except (OSError, subprocess.TimeoutExpired) as exc:
3446
+ raise LaunchError(f"cannot probe {command!r} for {flag}: {exc}") from exc
3447
+ # A failed help run proves nothing about the flag — and its ERROR text can carry
3448
+ # the very token being searched for ("error: unknown option --output-schema"), so
3449
+ # scanning it reported present on the known-opposite input (criterion round, #7).
3450
+ if probe.returncode != 0:
3451
+ detail = (probe.stderr or probe.stdout).strip()[:160]
3452
+ raise LaunchError(
3453
+ f"cannot probe {command!r} for {flag}: help exited {probe.returncode}"
3454
+ + (f" ({detail})" if detail else "")
3455
+ + " — presence cannot be read from a failed probe"
3456
+ )
3457
+ text = (probe.stdout + probe.stderr).replace(",", " ")
3458
+ registered = any(
3459
+ token == flag or token.startswith(f"{flag}=")
3460
+ for token in text.split()
3461
+ )
3462
+ if registered:
3463
+ print(f"schema flag {flag} for host {host}: present ({command})")
3464
+ return 0
3465
+ print(
3466
+ f"schema flag {flag} for host {host}: absent ({command} registers no such "
3467
+ f"option; dispatch this route as prose discipline)"
3468
+ )
3469
+ return SCHEMA_FLAG_ABSENT_EXIT
3470
+
3471
+
2965
3472
  # ── receipt production (the adapter side of the same contract) ──────────────
2966
3473
  # Everything above adjudicates receipts; nothing produced one. That gap is the whole
2967
3474
  # defect: the only party able to write a receipt was whoever ran the review, so the
@@ -2983,6 +3490,12 @@ RECEIPT_METHOD_ENV = "REVIEW_METHOD_ID"
2983
3490
  # multi-pass method, and a third-party one gets them right by not participating.
2984
3491
  RECEIPT_SEED_ENV = "REVIEW_ORDERING_SEED"
2985
3492
  RECEIPT_SWAP_ENV = "REVIEW_SWAP_GROUP"
3493
+ # The compiled criterion schema for THIS dispatch, set by the dispatch wrapper only
3494
+ # when it passed the schema to the host. Set, emission validates the result against it
3495
+ # and refuses to write a receipt for a class-less or out-of-enum finding — so the only
3496
+ # path to a receipt IS the structural accepting channel, and a bypassed check is an
3497
+ # unproven dispatch. Unset — every prose route — emission behaves exactly as today.
3498
+ RECEIPT_CRITERION_ENV = "REVIEW_CRITERION_SCHEMA"
2986
3499
  # The probe adjudicates a one-row plan, and every plan's one required row is the base
2987
3500
  # panel wearing its reserved id (`_review_identity_reason`). It had a name of its own —
2988
3501
  # `adapter-conformance-probe` — which made the probe the only plan in the system whose
@@ -3083,18 +3596,79 @@ def emit_receipt_command(
3083
3596
  if not key or not separator:
3084
3597
  raise LaunchError(f"--evidence takes key=value, got {item!r}")
3085
3598
  fields[key] = value
3599
+ # Present-but-empty is a configuration error, never a silent prose downgrade: an
3600
+ # exported empty value made the whole validation vacuous while the caller believed
3601
+ # it armed (criterion round, #3 — the empty-variable class the corpus already
3602
+ # names for shell checks).
3603
+ criterion_env = os.environ.get(RECEIPT_CRITERION_ENV)
3604
+ if criterion_env is not None and not criterion_env.strip():
3605
+ raise LaunchError(
3606
+ f"{RECEIPT_CRITERION_ENV} is set but empty; unset it for a prose-route "
3607
+ f"dispatch or point it at a compiled schema"
3608
+ )
3609
+ # Only a SUCCESSFUL dispatch is validated and stamped: a failed dispatch's receipt
3610
+ # records the failure and is already refused credit for exit != 0, and refusing to
3611
+ # write it would erase the failure record — while stamping it would claim a
3612
+ # validation that never ran.
3613
+ criterion_schema_path = criterion_env if status == 0 else None
3614
+ # ONE byte snapshot each for the schema and the result: validating one read and
3615
+ # hashing a second let the digest bind bytes the validation never saw — the same
3616
+ # false PASS whether the swap is a race or an adversary (criterion round, #4).
3617
+ criterion_digest = None
3618
+ result_bytes = None
3619
+ if criterion_schema_path:
3620
+ schema_file = pathlib.Path(criterion_schema_path).expanduser()
3621
+ try:
3622
+ schema_bytes = schema_file.read_bytes()
3623
+ except OSError as exc:
3624
+ raise LaunchError(
3625
+ f"{RECEIPT_CRITERION_ENV} names {schema_file}, which is not a readable "
3626
+ f"compiled schema: {exc}"
3627
+ ) from exc
3628
+ try:
3629
+ schema = json.loads(schema_bytes)
3630
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
3631
+ raise LaunchError(
3632
+ f"{RECEIPT_CRITERION_ENV} names {schema_file}, which is not a readable "
3633
+ f"compiled schema: {exc}"
3634
+ ) from exc
3635
+ classes = criterion_classes_of_schema(schema, str(schema_file))
3636
+ try:
3637
+ result_bytes = pathlib.Path(result_file).expanduser().read_bytes()
3638
+ except OSError as exc:
3639
+ raise LaunchError(f"cannot read result {result_file}: {exc}") from exc
3640
+ try:
3641
+ result = json.loads(result_bytes)
3642
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
3643
+ raise LaunchError(
3644
+ f"no receipt: the result is not JSON, so no finding in it carries a "
3645
+ f"class from the declared enum ({exc})"
3646
+ ) from exc
3647
+ violations = findings_violations(result, classes)
3648
+ if violations:
3649
+ raise LaunchError(
3650
+ "no receipt: " + "; ".join(violations) + " — a result the schema "
3651
+ "refuses is not published, and runtime never guesses a class"
3652
+ )
3653
+ criterion_digest = hashlib.sha256(schema_bytes).hexdigest()
3086
3654
  dispatch_id = uuid.uuid4().hex
3087
3655
  receipt = {
3088
3656
  "schema": RECEIPT_SCHEMA,
3089
3657
  "method_id": method_id,
3090
3658
  "dispatch_id": dispatch_id,
3091
3659
  "packet_sha256": _sha256_file(packet_file),
3092
- "result_sha256": _sha256_file(result_file),
3660
+ # The bytes the validation saw, when it ran; the file, when it did not.
3661
+ "result_sha256": (
3662
+ hashlib.sha256(result_bytes).hexdigest()
3663
+ if result_bytes is not None else _sha256_file(result_file)
3664
+ ),
3093
3665
  "provider": provider,
3094
3666
  "model": model,
3095
3667
  "effort": effort,
3096
3668
  "exit_status": status,
3097
3669
  }
3670
+ if criterion_digest:
3671
+ receipt["criterion_schema_sha256"] = criterion_digest
3098
3672
  if fields:
3099
3673
  receipt["evidence"] = fields
3100
3674
  for key, name in (("ordering_seed", RECEIPT_SEED_ENV), ("swap_group", RECEIPT_SWAP_ENV)):
@@ -3222,7 +3796,12 @@ def _merge_method_passes(method_id: str, group: list[dict], main_dispatch_id: st
3222
3796
  # `passes`, and for a lone receipt it is naturally the one-element set. Round 4 made
3223
3797
  # the multi-pass record faithful and this is the same sentence applied to the twin
3224
3798
  # F1 already names: asked of EACH receipt, singleton included.
3225
- for field_name in ("provider", "model", "effort", "packet_sha256"):
3799
+ for field_name in (
3800
+ "provider", "model", "effort", "packet_sha256", "criterion_schema_sha256",
3801
+ ):
3802
+ # criterion_schema_sha256 included with PRESENCE counted as a value: passes
3803
+ # validated against different schemas — or one validated and one not — ran
3804
+ # under different criteria and cannot be one method's passes.
3226
3805
  values = {json.dumps(receipt.get(field_name), sort_keys=True) for receipt in group}
3227
3806
  if len(values) > 1:
3228
3807
  raise LaunchError(
@@ -3586,7 +4165,8 @@ def routed_preset_names(presets: dict[str, Any]) -> set[str]:
3586
4165
  """
3587
4166
  return {
3588
4167
  name for name, preset in presets.items()
3589
- if isinstance(preset, dict) and ("mission" in preset or "trigger" in preset)
4168
+ if isinstance(preset, dict)
4169
+ and ("mission" in preset or "trigger" in preset or "criterion" in preset)
3590
4170
  }
3591
4171
 
3592
4172
 
@@ -4527,7 +5107,7 @@ def host_models(config: dict[str, Any], host: str, tiers: dict[str, Any]) -> lis
4527
5107
 
4528
5108
  def resolve_review_for_plan(
4529
5109
  review: ReviewPlan, config: dict[str, Any], host: str, main_model: str,
4530
- main_effort: str, context: str,
5110
+ main_effort: str, context: str, criterion: bool = False,
4531
5111
  ) -> tuple[dict, "ReviewReport | None"]:
4532
5112
  """(method registry, resolved report) for a review against the main seat.
4533
5113
 
@@ -4535,6 +5115,16 @@ def resolve_review_for_plan(
4535
5115
  what a fresh launch would; two copies of this would let the Custom hub show a report
4536
5116
  the next launch disagrees with."""
4537
5117
  if review.source != "composable":
5118
+ if criterion:
5119
+ # The clause renders only through composable review rows, so on a legacy
5120
+ # plan the toggle is accepted and then does nothing — a false success the
5121
+ # operator reads as discipline in force (criterion round, #0).
5122
+ raise LaunchError(
5123
+ f"{context} declares criterion = true, but its review uses the legacy "
5124
+ f"schema; the discipline clause renders only through composable review "
5125
+ f"rows, so the toggle would be silently inert — author a composable "
5126
+ f"[review] block or drop the toggle"
5127
+ )
4538
5128
  return {}, None
4539
5129
  provider = config["hosts"][host].get("provider")
4540
5130
  if not isinstance(provider, str) or not provider:
@@ -4544,7 +5134,7 @@ def resolve_review_for_plan(
4544
5134
  )
4545
5135
  methods = load_review_methods(config)
4546
5136
  main_seat = ReviewBinding(provider, host, main_model, main_effort)
4547
- return methods, resolve_composable_review(review, main_seat, config, methods)
5137
+ return methods, resolve_composable_review(review, main_seat, config, methods, criterion)
4548
5138
 
4549
5139
 
4550
5140
  def effective_review_family(
@@ -4586,6 +5176,7 @@ def apply_review_plan(plan: dict[str, Any], config: dict[str, Any], review: Revi
4586
5176
  methods, report = resolve_review_for_plan(
4587
5177
  review, config, plan["host"], plan["tiers"][plan["main_tier"]]["model"],
4588
5178
  tier_effort(plan, plan["main_tier"]), f"custom.{plan['preset']}",
5179
+ plan.get("criterion", False),
4589
5180
  )
4590
5181
  plan["review_plan"] = review
4591
5182
  # Editing on one host replaces that host's arm and leaves the others exactly as
@@ -4800,8 +5391,18 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
4800
5391
  # isolated mechanism is an invalid launch, and that has to fail while the plan is
4801
5392
  # being built rather than halfway through printing a contract.
4802
5393
  main_effort = frontier_effort if main_tier == "frontier" else tiers[main_tier]["effort"]
5394
+ # The criterion-discipline toggle. A BOOLEAN, deliberately: the criterion itself is
5395
+ # per-review and rides the packet, which packet_sha256 binds — a preset carrying its
5396
+ # content would put per-review text into the per-launch contract the golden pins.
5397
+ criterion = preset.get("criterion", False)
5398
+ if not isinstance(criterion, bool):
5399
+ raise LaunchError(
5400
+ f"presets.{preset_name}.criterion must be a boolean toggle; the criterion "
5401
+ f"itself is declared in the review packet, never in the preset"
5402
+ )
4803
5403
  review_methods, review_report = resolve_review_for_plan(
4804
- review, config, host, tiers[main_tier]["model"], main_effort, f"presets.{preset_name}"
5404
+ review, config, host, tiers[main_tier]["model"], main_effort,
5405
+ f"presets.{preset_name}", criterion,
4805
5406
  )
4806
5407
  codex_policy = preset.get("codex_execution_policy")
4807
5408
  claude_policy = preset.get("claude_permission_mode")
@@ -4894,6 +5495,7 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
4894
5495
  },
4895
5496
  "mission": mission,
4896
5497
  "trigger": trigger,
5498
+ "criterion": criterion,
4897
5499
  }
4898
5500
 
4899
5501
 
@@ -6061,6 +6663,13 @@ def preset_from_plan(
6061
6663
  "codex_execution_policy": plan["codex_execution_policy"],
6062
6664
  "claude_permission_mode": plan["claude_permission_mode"],
6063
6665
  }
6666
+ if plan.get("criterion"):
6667
+ # Save As from a criterion-toggled plan silently dropped the discipline: the
6668
+ # routed-name guard covers same-name shadowing, not a fresh name, and the
6669
+ # reload defaulted the absent field to false while Save reported success
6670
+ # (criterion round, #8). Written only when true, so every existing saved
6671
+ # preset stays byte-identical.
6672
+ fields["criterion"] = True
6064
6673
  if review_block is None:
6065
6674
  # Carrying both schemas on one preset is a validation error, so the legacy
6066
6675
  # names appear only when there is no authored block to write.
@@ -8941,6 +9550,28 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
8941
9550
  "--fold-receipts", nargs=3, metavar=("DIR", "PACKET_FILE", "MAIN_DISPATCH_ID"),
8942
9551
  help=f"fold a directory of receipts into one {RECEIPT_BUNDLE_SCHEMA} bundle on stdout",
8943
9552
  )
9553
+ # The criterion side of the same per-review file surface. Compile turns a criterion
9554
+ # document into the canonical findings schema and prints the packet record line;
9555
+ # check-findings is the structural accepting channel standalone; check-schema-flag
9556
+ # probes the installed backend for its structured-output flag, because a declared
9557
+ # capability is docs and the rule is probe-over-docs.
9558
+ parser.add_argument(
9559
+ "--compile-criterion", nargs=2, metavar=("CRITERION_FILE", "SCHEMA_OUT"),
9560
+ help=f"validate a criterion document, write its compiled findings schema, and "
9561
+ f"print the {CRITERION_RECORD_SCHEMA} record line the packet carries",
9562
+ )
9563
+ parser.add_argument(
9564
+ "--check-findings", nargs=2, metavar=("RESULT_FILE", "SCHEMA_FILE"),
9565
+ help="refuse a findings result that fails the compiled criterion schema, each "
9566
+ "violation by name; the same check runs inside --emit-receipt when "
9567
+ f"${RECEIPT_CRITERION_ENV} is set",
9568
+ )
9569
+ parser.add_argument(
9570
+ "--check-schema-flag", metavar="PROBE_HOST",
9571
+ help="probe the resolved backend binary for its structured-output flag; exit 0 "
9572
+ f"when registered, {SCHEMA_FLAG_ABSENT_EXIT} when the binary runs and does "
9573
+ f"not register it",
9574
+ )
8944
9575
  # REMAINDER and not "+": the adapter's own command line carries flags, and "+" stops
8945
9576
  # at the first token starting with a dash — which handed `--model` to the host
8946
9577
  # positional and rejected the run before the adapter was ever dispatched.
@@ -8956,7 +9587,8 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
8956
9587
  args = parser.parse_args(argv)
8957
9588
  hostless = (
8958
9589
  args.verify_receipts or args.emit_receipt or args.fold_receipts
8959
- or args.check_adapter
9590
+ or args.check_adapter or args.compile_criterion or args.check_findings
9591
+ or args.check_schema_flag
8960
9592
  )
8961
9593
  if args.host is None and not hostless:
8962
9594
  parser.error("the following arguments are required: host")
@@ -9031,9 +9663,25 @@ def verify_receipts_command(
9031
9663
  # under audit. Both halves, because either alone is satisfiable by the wrong thing —
9032
9664
  # a digest agreeing with the anchor says these bytes were hashed, and the plan the
9033
9665
  # bytes carry says WHICH review they were hashed for.
9666
+ # Whether the plan declared the criterion discipline: read from the rows' rendered
9667
+ # instructions, where the launch snapshotted it — a ReviewPlan/v1 field would be a
9668
+ # schema change restating what the instruction already carries.
9669
+ criterion_active = any(
9670
+ CRITERION_CLAUSE_MARKER in row.instruction
9671
+ for row in (report.base, *report.methods)
9672
+ if row.status != STATUS_DROPPED
9673
+ )
9674
+ criterion_digest: "str | None" = None
9034
9675
  if packet_path is not None:
9035
9676
  packet_file = pathlib.Path(packet_path).expanduser()
9036
- digest = _sha256_file(str(packet_file))
9677
+ # ONE byte snapshot: hashing the path and then reopening it as text let the
9678
+ # equality speak for one file state and the plan/criterion extraction for
9679
+ # another (criterion round, #6 — the emit-side twin is #4).
9680
+ try:
9681
+ packet_bytes = packet_file.read_bytes()
9682
+ except OSError as exc:
9683
+ raise LaunchError(f"cannot read the packet {packet_file}: {exc}") from exc
9684
+ digest = hashlib.sha256(packet_bytes).hexdigest()
9037
9685
  anchor = bundle.get("packet_sha256") if isinstance(bundle, dict) else None
9038
9686
  if digest != anchor:
9039
9687
  raise LaunchError(
@@ -9042,8 +9690,8 @@ def verify_receipts_command(
9042
9690
  f"these bytes"
9043
9691
  )
9044
9692
  try:
9045
- packet_text = packet_file.read_text(encoding="utf-8")
9046
- except (OSError, UnicodeDecodeError) as exc:
9693
+ packet_text = packet_bytes.decode("utf-8")
9694
+ except UnicodeDecodeError as exc:
9047
9695
  raise LaunchError(f"cannot read the packet {packet_file}: {exc}") from exc
9048
9696
  carried = extract_review_plan_v1(packet_text)
9049
9697
  if carried is None:
@@ -9056,6 +9704,16 @@ def verify_receipts_command(
9056
9704
  f"the supplied packet carries a different {REVIEW_PLAN_SCHEMA} record than "
9057
9705
  f"{plan_path}; these receipts evidence a review of another plan"
9058
9706
  )
9707
+ if criterion_active:
9708
+ # A plan whose rows carry the discipline clause declared a criterion, and a
9709
+ # packet that omits the record cannot verify: extract_criterion refuses the
9710
+ # absence, the malformed record and the second record each by name. The
9711
+ # digest is a RECOMPILATION from the packet's own bytes — never read off
9712
+ # the receipts, which are the artifacts under audit.
9713
+ document = extract_criterion(packet_text, "the supplied packet")
9714
+ criterion_digest = hashlib.sha256(
9715
+ criterion_schema_bytes(document)
9716
+ ).hexdigest()
9059
9717
  # The offer that SERVES THE ROW'S HOST, selected by `select_capability_offer` — the
9060
9718
  # same function `derive_review_mechanism` seats a method with, rather than a second
9061
9719
  # loop written to the same description. The first offer for the operation was taken
@@ -9110,9 +9768,26 @@ def verify_receipts_command(
9110
9768
  method, capability, row_host
9111
9769
  )["evidence"]
9112
9770
  verified, verdicts = verify_review_receipts(
9113
- report, bundle, required_controls, required_evidence
9771
+ report, bundle, required_controls, required_evidence, criterion_digest
9114
9772
  )
9115
- print(render_receipt_verdicts(verified, verdicts, packet_bound=packet_path is not None))
9773
+ if not criterion_active:
9774
+ criterion_note = ""
9775
+ elif criterion_digest is not None:
9776
+ criterion_note = (
9777
+ " criterion=declared and bound: receipts carrying criterion_schema_sha256 "
9778
+ "were held to the schema recompiled from the packet's record; a receipt "
9779
+ "without it ran prose discipline — steering, not structural enforcement"
9780
+ )
9781
+ else:
9782
+ criterion_note = (
9783
+ " criterion=declared but UNBOUND: no packet was supplied, so the declared "
9784
+ "criterion was recompiled against nothing and no schema digest above was "
9785
+ "adjudicated"
9786
+ )
9787
+ print(render_receipt_verdicts(
9788
+ verified, verdicts, packet_bound=packet_path is not None,
9789
+ criterion_note=criterion_note,
9790
+ ))
9116
9791
  return 0 if verified.achievement == ACHIEVEMENT_COMPLETE else 1
9117
9792
 
9118
9793
 
@@ -9162,6 +9837,14 @@ def main(argv: list[str]) -> int:
9162
9837
  return emit_receipt_command(*args.emit_receipt, evidence=args.evidence)
9163
9838
  if args.fold_receipts:
9164
9839
  return fold_receipts_command(*args.fold_receipts)
9840
+ if args.compile_criterion:
9841
+ return compile_criterion_command(*args.compile_criterion)
9842
+ if args.check_findings:
9843
+ return check_findings_command(*args.check_findings)
9844
+ if args.check_schema_flag:
9845
+ return check_schema_flag_command(
9846
+ args.check_schema_flag, args.config.expanduser()
9847
+ )
9165
9848
  if args.check_adapter:
9166
9849
  if len(args.check_adapter) < 2:
9167
9850
  raise LaunchError("--check-adapter takes a seat and then the adapter command")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-bios",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "releaseDate": "2026-08-19",
5
5
  "description": "A thin, low-level instruction layer for LLM CLI agents: one set of principles and behavior whichever model you run. Deploys into $HOME by copy via an explicit `agent-bios install`.",
6
6
  "bin": {
package/provenance.json CHANGED
@@ -1 +1 @@
1
- {"commit":"471ff43022a60490b5e0e788f5ba6dec3a89e992","committedAt":"2026-08-19T07:44:53+09:00","dirty":false}
1
+ {"commit":"7d37b87e38e892108e7cd7bd0a397fd30316065c","committedAt":"2026-08-19T11:53:21+09:00","dirty":false}