syncmill 0.2.0__py3-none-any.whl

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 (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
@@ -0,0 +1,2380 @@
1
+ """Read-only comparison of room candidates (#172, PR 1).
2
+
3
+ One read model, :func:`build_room_comparison`, feeds every surface: the
4
+ ``room compare`` text table, its ``--json`` form, and (later) the TUI table,
5
+ candidate Details and review-summary export. The model only reports what is
6
+ recorded and never upgrades an absence into a verdict:
7
+
8
+ - **The round is the comparison unit.** A room keeps one source fingerprint
9
+ (a change makes the room stale) and ``run_round`` builds one gate per round,
10
+ so the candidates of one round share their source and gate settings by
11
+ construction. Round 1 always starts from a fresh snapshot; the lineage of
12
+ later rounds is not persisted, so their starting tree reads ``not recorded``.
13
+ - **A missing gate report is not a pass, and missing usage is not zero.** A
14
+ manifest is mutable display data, so its gate is read only from a recognized
15
+ record and cross-checked against the authoritative SQLite ``eligible``
16
+ column; a contradiction reads ``unknown`` instead of being believed.
17
+ - **Damage stays visible.** A candidate whose artifact fails its bindings or
18
+ digest keeps its row (and an apply blocker); a member with no candidate is
19
+ listed with whatever progress evidence exists.
20
+
21
+ Nothing here writes: the caller owns the open :class:`RoomStore`. A flat
22
+ top-level module; it imports ``rooms`` and ``artifacts`` and nothing imports it
23
+ back except the CLI.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ import time
30
+ from collections.abc import Callable
31
+ from dataclasses import dataclass, field, replace
32
+ from typing import Any
33
+
34
+ from syncmill.artifacts import printable_name
35
+ from syncmill.models import TokenUsage
36
+ from syncmill.rooms import (
37
+ _ROOM_CANDIDATE_CONTEXT_MAX_BYTES,
38
+ _SELECTABLE_ROOM_STATES,
39
+ ProvenanceCheck,
40
+ RoomError,
41
+ RoomStore,
42
+ SelectionCheck,
43
+ round_conversation_status,
44
+ round_provenance_status,
45
+ selection_record_status,
46
+ )
47
+
48
+ SCHEMA_VERSION = 1
49
+ KIND = "syncmill.room-compare"
50
+
51
+ #: A patch above this size is flagged ``large`` (display only).
52
+ LARGE_PATCH_BYTES = 1024 * 1024
53
+
54
+ _ATTEMPT_STATES = frozenset({"completed", "failed", "timeout"})
55
+ _IN_PROGRESS_MEMBER_STATES = frozenset({"queued", "running"})
56
+
57
+ #: The same union the TUI room poll and ``room candidates`` treat as a damaged
58
+ #: record rather than a crash.
59
+ _ROW_ERRORS = (RoomError, KeyError, TypeError, AttributeError, OSError, ValueError)
60
+
61
+ SourceCheck = Callable[[], str]
62
+
63
+ _UNDECIDABLE_REASON = {
64
+ "no_report": "no gate report recorded",
65
+ "unknown": "no completed candidate with a usable record",
66
+ "mixed": "gate records disagree within the round",
67
+ }
68
+
69
+
70
+ class UnknownRoundError(RoomError):
71
+ """The requested round number does not exist in the room (a bad argument,
72
+ not a damaged store)."""
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class GateCheckView:
77
+ command: str
78
+ passed: bool
79
+ exit_code: int | None = None
80
+ duration_seconds: float | None = None
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class GateView:
85
+ #: passed | failed | not_run | no_report | unknown
86
+ verdict: str
87
+ reason: str | None = None
88
+ checks: tuple[GateCheckView, ...] = ()
89
+
90
+ @property
91
+ def commands(self) -> tuple[str, ...]:
92
+ return tuple(check.command for check in self.checks)
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class CandidateRow:
97
+ candidate_id: str
98
+ member_id: str
99
+ provider_id: str
100
+ #: None only on a damaged row whose stored attempt is malformed.
101
+ attempt: int | None
102
+ state: str
103
+ eligible: bool
104
+ selected: bool
105
+ patch_sha256: str
106
+ #: Display only: a per-snapshot synthetic commit that can differ between
107
+ #: rounds with identical source, so never a comparability key.
108
+ base_commit: str
109
+ readable: bool
110
+ unreadable_reason: str | None
111
+ gate: GateView
112
+ duration_seconds: float | None
113
+ usage: TokenUsage | None
114
+ #: None = the file set is unknown (unreadable or malformed), never "empty".
115
+ files_changed: tuple[str, ...] | None
116
+ patch_bytes: int | None
117
+ patch_binary: bool | None
118
+ patch_large: bool | None
119
+ not_eligible_reason: str | None
120
+ apply_blockers: tuple[str, ...]
121
+ warnings: tuple[str, ...]
122
+ # Display metadata for the candidate Details view. Identifiers and numbers
123
+ # reach the JSON; ``error`` does not (it can quote agent output).
124
+ created_at: str | None = None
125
+ output_sha256: str | None = None
126
+ exit_code: int | None = None
127
+ output_bytes: int | None = None
128
+ output_truncated: bool | None = None
129
+ provider_session_id: str | None = None
130
+ error: str | None = None
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class MissingMember:
135
+ member_id: str
136
+ provider_id: str
137
+ #: in_progress | no_candidate
138
+ status: str
139
+ evidence: tuple[str, ...]
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class GateProfile:
144
+ #: commands | no_report | unknown | mixed
145
+ kind: str
146
+ commands: tuple[str, ...] = ()
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class FileOverlap:
151
+ compared: tuple[str, ...]
152
+ common: tuple[str, ...]
153
+ only: dict[str, tuple[str, ...]]
154
+ partial: dict[str, tuple[str, ...]]
155
+ no_changes: tuple[str, ...]
156
+ unknown: tuple[str, ...]
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class LineageView:
161
+ """A round's recorded effective starting tree (``rounds.provenance_json``).
162
+
163
+ Values are shown only when the record is ``recorded``: a malformed record
164
+ is reported by its reason (a round warning), never believed."""
165
+
166
+ #: recorded | not_recorded | malformed
167
+ status: str
168
+ reason: str | None = None
169
+ #: the lineage the user asked for: continue-own | fresh | branch-from
170
+ requested: str | None = None
171
+ #: snapshot | branch | continued
172
+ tree: str | None = None
173
+ base_candidate_id: str | None = None
174
+ base_patch_sha256: str | None = None
175
+ base_round_number: int | None = None
176
+ #: continued only: member id -> (parent candidate id, parent round), or
177
+ #: None when the previous round held no single candidate for the member.
178
+ parents: dict[str, tuple[str, int] | None] = field(default_factory=dict)
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class GateSettingsView:
183
+ """The gate settings a round recorded, kept apart from the gate REPORTS
184
+ (what actually ran) and cross-checked against them."""
185
+
186
+ #: recorded | not_recorded | malformed
187
+ status: str
188
+ #: commands | none (recorded only)
189
+ kind: str | None = None
190
+ commands: tuple[str, ...] = ()
191
+ timeout_seconds: int | None = None
192
+ #: consistent | contradicted | indeterminate | n/a
193
+ consistency: str = "n/a"
194
+
195
+
196
+ @dataclass(frozen=True)
197
+ class MemberConversation:
198
+ #: cold | resume | fork — what the provider was handed, not what it did
199
+ session: str
200
+ #: full | delta
201
+ context: str
202
+ since_round: int | None
203
+ #: candidate ids whose output excerpts entered the prompt, oldest first
204
+ candidates: tuple[str, ...]
205
+ #: older eligible candidates were left out for the prompt size limit
206
+ omitted: bool
207
+ #: included | dropped | none
208
+ memory: str
209
+
210
+
211
+ @dataclass(frozen=True)
212
+ class ConversationView:
213
+ """A round's recorded per-member conversations
214
+ (``rounds.conversation_json``); values only when ``recorded``."""
215
+
216
+ #: recorded | not_recorded | malformed
217
+ status: str
218
+ reason: str | None = None
219
+ members: dict[str, MemberConversation] = field(default_factory=dict)
220
+
221
+
222
+ @dataclass(frozen=True)
223
+ class RoundGroup:
224
+ number: int
225
+ round_id: str
226
+ state: str
227
+ #: Report-era v1 field, unchanged: ``snapshot`` for round 1, else
228
+ #: ``not_recorded``. The recorded tree is :attr:`lineage`.
229
+ starting_tree: str
230
+ #: Report-derived (what the candidates' gate reports show); the recorded
231
+ #: configuration is :attr:`gate_settings`.
232
+ gate_profile: GateProfile
233
+ rows: tuple[CandidateRow, ...]
234
+ missing: tuple[MissingMember, ...]
235
+ overlap: FileOverlap
236
+ warnings: tuple[str, ...]
237
+ lineage: LineageView = field(default_factory=lambda: LineageView("not_recorded"))
238
+ gate_settings: GateSettingsView = field(
239
+ default_factory=lambda: GateSettingsView("not_recorded")
240
+ )
241
+ conversation: ConversationView = field(
242
+ default_factory=lambda: ConversationView("not_recorded")
243
+ )
244
+
245
+
246
+ @dataclass(frozen=True)
247
+ class SelectionView:
248
+ """The room's current human selection and how its record holds up.
249
+
250
+ Record values are shown only when the record is ``bound``: a mismatched
251
+ record is reported by its reason, never believed."""
252
+
253
+ #: bound | mismatch | not_recorded
254
+ status: str
255
+ candidate_id: str | None
256
+ reason: str | None = None
257
+ patch_sha256: str | None = None
258
+ confirmed_revision: int | None = None
259
+ revision: int | None = None
260
+ rationale: str | None = None
261
+ surface: str | None = None
262
+ selected_at: str | None = None
263
+
264
+
265
+ @dataclass(frozen=True)
266
+ class RoomComparison:
267
+ room_id: str
268
+ room_state: str
269
+ revision: int
270
+ selected_candidate_id: str | None
271
+ #: current | changed | unavailable | not_recorded
272
+ source_status: str
273
+ source_reason: str | None
274
+ round_count: int
275
+ groups: tuple[RoundGroup, ...]
276
+ #: Only with ``all_rounds``: rounds grouped by an equal gate profile.
277
+ cross_round: tuple[dict[str, Any], ...] = field(default=())
278
+ #: Only with ``all_rounds``: rounds grouped by equal RECORDED gate settings
279
+ #: (commands and timeout); rounds without a usable record stand alone.
280
+ cross_round_settings: tuple[dict[str, Any], ...] = field(default=())
281
+ #: Rounds whose own record could not be projected; always listed, in every
282
+ #: mode, so damage is never presented as absence.
283
+ damaged_rounds: tuple[dict[str, str], ...] = field(default=())
284
+ #: None when nothing is selected and no selection record exists.
285
+ selection: SelectionView | None = None
286
+
287
+
288
+ def _is_int(value: object) -> bool:
289
+ return type(value) is int # bool is an int subclass; True must not pass for 1
290
+
291
+
292
+ def _is_number(value: object) -> bool:
293
+ return type(value) in (int, float)
294
+
295
+
296
+ def _str(value: object) -> str | None:
297
+ return value if isinstance(value, str) else None
298
+
299
+
300
+ def _count(value: object) -> int | None:
301
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
302
+ return None
303
+ return value
304
+
305
+
306
+ def _seconds(value: object) -> float | None:
307
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
308
+ return None
309
+ try:
310
+ if not math.isfinite(value) or value < 0:
311
+ return None
312
+ except OverflowError:
313
+ return None
314
+ return float(value)
315
+
316
+
317
+ def _positive(value: object) -> int | None:
318
+ count = _count(value)
319
+ return count if count else None
320
+
321
+
322
+ def _attempt(value: object) -> int:
323
+ attempt = _positive(value)
324
+ if attempt is None:
325
+ raise ValueError(f"malformed attempt {value!r}")
326
+ return attempt
327
+
328
+
329
+ def _files(value: object) -> tuple[str, ...] | None:
330
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
331
+ return None
332
+ return tuple(sorted(set(value)))
333
+
334
+
335
+ def _parse_gate(value: object) -> tuple[bool, GateView | None]:
336
+ """``(recognized, view)``; ``view`` is None for an explicit ``null`` gate.
337
+
338
+ Recognized means exactly the ``_gate_json`` shape ``RoomCandidateStore.save``
339
+ writes: ``passed``/``score``/``checks``, each check carrying ``command``,
340
+ ``passed``, ``exit_code``, ``output`` and ``duration_seconds``, and a
341
+ ``passed`` flag that agrees with the checks (GateReport.passed is "every
342
+ check passed").
343
+ """
344
+ if value is None:
345
+ return True, None
346
+ if (
347
+ not isinstance(value, dict)
348
+ or type(value.get("passed")) is not bool
349
+ or not _is_number(value.get("score"))
350
+ or not isinstance(value.get("checks"), list)
351
+ ):
352
+ return False, None
353
+ parsed: list[GateCheckView] = []
354
+ for check in value["checks"]:
355
+ if (
356
+ not isinstance(check, dict)
357
+ or not isinstance(check.get("command"), str)
358
+ or type(check.get("passed")) is not bool
359
+ or not _is_int(check.get("exit_code"))
360
+ or not isinstance(check.get("output"), str)
361
+ or not _is_number(check.get("duration_seconds"))
362
+ ):
363
+ return False, None
364
+ parsed.append(
365
+ GateCheckView(
366
+ check["command"],
367
+ check["passed"],
368
+ exit_code=check["exit_code"],
369
+ duration_seconds=_seconds(check["duration_seconds"]),
370
+ )
371
+ )
372
+ if value["passed"] != all(check.passed for check in parsed):
373
+ return False, None
374
+ return True, GateView(
375
+ "passed" if value["passed"] else "failed", checks=tuple(parsed)
376
+ )
377
+
378
+
379
+ def _gate_verdict(
380
+ *,
381
+ state: str,
382
+ eligible: bool,
383
+ manifest: dict[str, Any] | None,
384
+ patch_nonempty: bool | None,
385
+ ) -> GateView:
386
+ if state != "completed":
387
+ return GateView("not_run", "agent did not complete")
388
+ if manifest is None:
389
+ return GateView("unknown", "artifact unreadable")
390
+ version = manifest.get("schema_version")
391
+ if not (_is_int(version) and version == 1) or manifest.get("state") != state:
392
+ return GateView("unknown", "unrecognized record")
393
+ # save() always writes the key; a missing one is not an explicit null.
394
+ if "gate" not in manifest:
395
+ return GateView("unknown", "unrecognized record")
396
+ recognized, view = _parse_gate(manifest["gate"])
397
+ if not recognized:
398
+ return GateView("unknown", "unrecognized record")
399
+ # eligible = completed AND non-empty patch AND (gate null OR gate passed)
400
+ # (RoomCandidateStore.save). With a verified non-empty patch the stored
401
+ # eligibility therefore pins the gate outcome; an empty patch pins nothing.
402
+ if patch_nonempty:
403
+ gate_ok = view is None or view.verdict == "passed"
404
+ if gate_ok != eligible:
405
+ return GateView("unknown", "record contradicts stored eligibility")
406
+ return view if view is not None else GateView("no_report")
407
+
408
+
409
+ #: The manifest fields ``RoomCoordinator.apply`` converts when it builds the
410
+ #: run artifact, with the exact conversion it applies.
411
+ _APPLY_CONSUMED_FIELDS = (
412
+ ("exit_code", int),
413
+ ("duration_seconds", float),
414
+ ("files_changed", list),
415
+ )
416
+
417
+
418
+ def _apply_unusable_fields(manifest: dict[str, Any]) -> list[str]:
419
+ """Fields apply would fail to convert. Mirrors apply's own conversions
420
+ rather than this module's stricter display validation, so a blocker is
421
+ reported exactly when apply would raise."""
422
+ bad: list[str] = []
423
+ for key, convert in _APPLY_CONSUMED_FIELDS:
424
+ try:
425
+ convert(manifest[key])
426
+ except (KeyError, TypeError, ValueError, OverflowError):
427
+ bad.append(key)
428
+ return bad
429
+
430
+
431
+ def _not_eligible_reason(
432
+ *, state: str, patch_nonempty: bool | None, gate: GateView
433
+ ) -> str:
434
+ if state != "completed":
435
+ return "agent did not complete"
436
+ if patch_nonempty is False:
437
+ return "no patch captured"
438
+ if gate.verdict == "failed":
439
+ return "quality gate failed"
440
+ return "reason not determinable from the record"
441
+
442
+
443
+ @dataclass(frozen=True)
444
+ class GateRead:
445
+ """A candidate's gate as the comparison decides it, with the bound
446
+ manifest the verdict was read from (None when the artifact is unreadable)."""
447
+
448
+ gate: GateView
449
+ manifest: dict[str, Any] | None
450
+ unreadable: str | None
451
+ patch_total: int | None
452
+
453
+
454
+ def read_candidate_gate(
455
+ store: RoomStore, candidate_id: str, *, state: str, eligible: bool
456
+ ) -> GateRead:
457
+ """Verify a candidate's artifact and decide its gate: the one path both
458
+ the comparison rows and raw gate-output export take, so they never
459
+ disagree about which checks exist. ``state``/``eligible`` come from the
460
+ authoritative SQLite row."""
461
+ manifest: dict[str, Any] | None = None
462
+ unreadable: str | None = None
463
+ try:
464
+ manifest = store.load_candidate_patch_head(candidate_id, 0)
465
+ # Apply loads the whole artifact, so a missing or altered output file
466
+ # blocks it too. Verify the output digest without keeping the body.
467
+ store.load_candidate_checkpoint(candidate_id, 0)
468
+ except _ROW_ERRORS as exc:
469
+ manifest = None
470
+ unreadable = str(exc) or type(exc).__name__
471
+
472
+ patch_total = _count(manifest.get("patch_total_bytes")) if manifest else None
473
+ patch_nonempty = None if patch_total is None else patch_total > 0
474
+ gate = _gate_verdict(
475
+ state=state, eligible=eligible, manifest=manifest, patch_nonempty=patch_nonempty
476
+ )
477
+ return GateRead(gate, manifest, unreadable, patch_total)
478
+
479
+
480
+ def _project_row(
481
+ store: RoomStore,
482
+ candidate: dict[str, Any],
483
+ *,
484
+ selected_id: str | None,
485
+ room_state: str,
486
+ source_status: str,
487
+ source_reason: str | None,
488
+ ) -> CandidateRow:
489
+ candidate_id = str(candidate["id"])
490
+ state = str(candidate["state"])
491
+ eligible = bool(candidate["eligible"])
492
+ warnings: list[str] = []
493
+ read = read_candidate_gate(store, candidate_id, state=state, eligible=eligible)
494
+ manifest, unreadable, patch_total, gate = (
495
+ read.manifest, read.unreadable, read.patch_total, read.gate
496
+ )
497
+ patch_nonempty = None if patch_total is None else patch_total > 0
498
+ if state not in _ATTEMPT_STATES:
499
+ warnings.append(f"unknown attempt state {state!r}")
500
+ if eligible and (state != "completed" or patch_nonempty is False):
501
+ warnings.append("stored eligibility contradicts the recorded attempt")
502
+
503
+ files = duration = usage = binary = None
504
+ exit_code = output_bytes = truncated = session = error = None
505
+ if manifest is not None:
506
+ raw_exit = manifest.get("exit_code")
507
+ exit_code = raw_exit if _is_int(raw_exit) else None
508
+ output_bytes = _count(manifest.get("output_bytes"))
509
+ raw_truncated = manifest.get("output_truncated")
510
+ truncated = raw_truncated if type(raw_truncated) is bool else None
511
+ session = _str(manifest.get("provider_session_id"))
512
+ error = _str(manifest.get("error"))
513
+ files = _files(manifest.get("files_changed"))
514
+ if files is None:
515
+ warnings.append("files_changed is malformed; file set unknown")
516
+ duration = _seconds(manifest.get("duration_seconds"))
517
+ usage = TokenUsage.from_mapping(manifest.get("usage"))
518
+ raw_binary = manifest.get("patch_binary")
519
+ binary = raw_binary if isinstance(raw_binary, bool) else None
520
+
521
+ blockers: list[str] = []
522
+ if unreadable is not None:
523
+ blockers.append(f"artifact unreadable: {unreadable}")
524
+ elif manifest is not None and (unusable := _apply_unusable_fields(manifest)):
525
+ blockers.append(
526
+ "artifact metadata apply cannot use: " + ", ".join(unusable)
527
+ )
528
+ not_eligible = None
529
+ if not eligible:
530
+ not_eligible = _not_eligible_reason(
531
+ state=state, patch_nonempty=patch_nonempty, gate=gate
532
+ )
533
+ blockers.append(f"not eligible: {not_eligible}")
534
+ blockers.extend(_room_blockers(room_state, source_status, source_reason))
535
+
536
+ return CandidateRow(
537
+ candidate_id=candidate_id,
538
+ member_id=str(candidate["member_id"]),
539
+ provider_id=str(candidate["provider_id"]),
540
+ attempt=_attempt(candidate["attempt"]),
541
+ state=state,
542
+ eligible=eligible,
543
+ selected=candidate_id == selected_id,
544
+ patch_sha256=str(candidate["patch_sha256"]),
545
+ base_commit=str(candidate["base_commit"]),
546
+ readable=manifest is not None,
547
+ unreadable_reason=unreadable,
548
+ gate=gate,
549
+ duration_seconds=duration,
550
+ usage=usage,
551
+ files_changed=files,
552
+ patch_bytes=patch_total,
553
+ patch_binary=binary,
554
+ patch_large=None if patch_total is None else patch_total > LARGE_PATCH_BYTES,
555
+ not_eligible_reason=not_eligible,
556
+ apply_blockers=tuple(blockers),
557
+ warnings=tuple(warnings),
558
+ created_at=_str(candidate.get("created_at")),
559
+ output_sha256=_str(candidate.get("output_sha256")),
560
+ exit_code=exit_code,
561
+ output_bytes=output_bytes,
562
+ output_truncated=truncated,
563
+ provider_session_id=session,
564
+ error=error,
565
+ )
566
+
567
+
568
+ def _room_blockers(
569
+ room_state: str, source_status: str, source_reason: str | None
570
+ ) -> list[str]:
571
+ blockers: list[str] = []
572
+ if source_status == "changed":
573
+ blockers.append("source changed since the room started")
574
+ elif source_status == "unavailable":
575
+ blockers.append(f"source check unavailable: {source_reason}")
576
+ if room_state not in _SELECTABLE_ROOM_STATES:
577
+ blockers.append(f"room is {room_state}")
578
+ return blockers
579
+
580
+
581
+ def _selection_view(selected_id: object, check: SelectionCheck) -> SelectionView:
582
+ candidate_id = selected_id if isinstance(selected_id, str) else None
583
+ record = check.record
584
+ if check.status != "bound" or record is None:
585
+ return SelectionView(check.status, candidate_id, check.reason)
586
+ return SelectionView(
587
+ "bound",
588
+ candidate_id,
589
+ patch_sha256=record["patch_sha256"],
590
+ confirmed_revision=record["confirmed_revision"],
591
+ revision=record["revision"],
592
+ rationale=record["rationale"],
593
+ surface=record["surface"],
594
+ selected_at=record["selected_at"],
595
+ )
596
+
597
+
598
+ def _selection_blockers(room: dict[str, Any], candidate: dict[str, Any]) -> list[str]:
599
+ """Apply refuses an inconsistent selection record; say so on the selected
600
+ row (the same validator apply runs, on the same candidate row)."""
601
+ selected_id = room.get("selected_candidate_id")
602
+ if not selected_id or candidate.get("id") != selected_id:
603
+ return []
604
+ check = selection_record_status(
605
+ room.get("selection_json"),
606
+ selected_candidate_id=selected_id,
607
+ candidate_row=candidate,
608
+ )
609
+ if check.status != "mismatch":
610
+ return []
611
+ return [f"selection record inconsistent: {check.reason}; select it again"]
612
+
613
+
614
+ def _damaged_row(
615
+ candidate: dict[str, Any], reason: str, *, selected_id: str | None, room_blockers: list[str]
616
+ ) -> CandidateRow:
617
+ """A candidate whose own SQLite row could not be projected. Every field is
618
+ taken defensively; the row stays in the comparison as unreadable."""
619
+ candidate_id = str(candidate.get("id"))
620
+ stored = candidate.get("eligible")
621
+ warnings = () if stored in (0, 1) else ("stored eligibility is unreadable",)
622
+ return CandidateRow(
623
+ candidate_id=candidate_id,
624
+ member_id=str(candidate.get("member_id")),
625
+ provider_id=str(candidate.get("provider_id")),
626
+ attempt=_positive(candidate.get("attempt")),
627
+ state=str(candidate.get("state")),
628
+ # Keep the stored verdict when it is readable; the unreadable blocker
629
+ # below already prevents it from reading as ready.
630
+ eligible=stored == 1 and not isinstance(stored, bool),
631
+ selected=candidate_id == selected_id,
632
+ patch_sha256=str(candidate.get("patch_sha256")),
633
+ base_commit=str(candidate.get("base_commit")),
634
+ readable=False,
635
+ unreadable_reason=f"candidate record unreadable: {reason}",
636
+ gate=GateView("unknown", "candidate record unreadable"),
637
+ duration_seconds=None,
638
+ usage=None,
639
+ files_changed=None,
640
+ patch_bytes=None,
641
+ patch_binary=None,
642
+ patch_large=None,
643
+ not_eligible_reason=None,
644
+ apply_blockers=(f"artifact unreadable: candidate record unreadable: {reason}", *room_blockers),
645
+ warnings=warnings,
646
+ )
647
+
648
+
649
+ def project_candidate(
650
+ store: RoomStore,
651
+ candidate: dict[str, Any],
652
+ *,
653
+ room: dict[str, Any],
654
+ source_status: str,
655
+ source_reason: str | None,
656
+ ) -> CandidateRow:
657
+ """One candidate as a :class:`CandidateRow` — the single projection the
658
+ CLI, the TUI Compare view and candidate Details all read. Never raises for
659
+ damage inside the candidate's own record: that becomes an unreadable row."""
660
+ room_state = str(room["state"])
661
+ selected_id = room.get("selected_candidate_id")
662
+ try:
663
+ row = _project_row(
664
+ store,
665
+ candidate,
666
+ selected_id=selected_id,
667
+ room_state=room_state,
668
+ source_status=source_status,
669
+ source_reason=source_reason,
670
+ )
671
+ except _ROW_ERRORS as exc:
672
+ row = _damaged_row(
673
+ candidate,
674
+ str(exc) or type(exc).__name__,
675
+ selected_id=selected_id,
676
+ room_blockers=_room_blockers(room_state, source_status, source_reason),
677
+ )
678
+ if blockers := _selection_blockers(room, candidate):
679
+ row = replace(row, apply_blockers=(*row.apply_blockers, *blockers))
680
+ return row
681
+
682
+
683
+ def _gate_profile(rows: list[CandidateRow]) -> tuple[GateProfile, list[str]]:
684
+ usable = [r for r in rows if r.state == "completed" and r.gate.verdict != "unknown"]
685
+ if not usable:
686
+ return GateProfile("unknown"), []
687
+ gated = [r for r in usable if r.gate.verdict in {"passed", "failed"}]
688
+ report_less = [r for r in usable if r.gate.verdict == "no_report"]
689
+ command_sets = {r.gate.commands for r in gated}
690
+ if (gated and report_less) or len(command_sets) > 1:
691
+ # One gate is built per round, so this only arises from a damaged or
692
+ # edited record.
693
+ names = ", ".join(r.candidate_id for r in usable)
694
+ return GateProfile("mixed"), [
695
+ f"gate records disagree within the round (candidates: {names})"
696
+ ]
697
+ if gated:
698
+ return GateProfile("commands", next(iter(command_sets))), []
699
+ return GateProfile("no_report"), []
700
+
701
+
702
+ def _provenance_views(
703
+ check: ProvenanceCheck, rows: list[CandidateRow]
704
+ ) -> tuple[LineageView, GateSettingsView, list[str]]:
705
+ if check.status != "recorded" or check.record is None:
706
+ warnings = (
707
+ [f"round provenance record unreadable: {check.reason}"]
708
+ if check.status == "malformed"
709
+ else []
710
+ )
711
+ return (
712
+ LineageView(check.status, reason=check.reason),
713
+ GateSettingsView(check.status),
714
+ warnings,
715
+ )
716
+ record = check.record
717
+ tree = record["tree"]
718
+ parents: dict[str, tuple[str, int] | None] = {}
719
+ if tree["kind"] == "continued":
720
+ parents = {
721
+ member_id: (
722
+ None
723
+ if parent is None
724
+ else (parent["parent_candidate_id"], parent["parent_round_number"])
725
+ )
726
+ for member_id, parent in tree["members"].items()
727
+ }
728
+ lineage = LineageView(
729
+ "recorded",
730
+ requested=record["requested_lineage"],
731
+ tree=tree["kind"],
732
+ base_candidate_id=tree.get("base_candidate_id"),
733
+ base_patch_sha256=tree.get("base_patch_sha256"),
734
+ base_round_number=tree.get("base_round_number"),
735
+ parents=parents,
736
+ )
737
+ gate = record["gate"]
738
+ commands = tuple(gate.get("commands", ()))
739
+ consistency, warnings = _gate_consistency(gate["kind"], commands, rows)
740
+ settings = GateSettingsView(
741
+ "recorded",
742
+ kind=gate["kind"],
743
+ commands=commands,
744
+ timeout_seconds=gate.get("timeout_seconds"),
745
+ consistency=consistency,
746
+ )
747
+ return lineage, settings, warnings
748
+
749
+
750
+ def _conversation_view(check: ProvenanceCheck) -> tuple[ConversationView, list[str]]:
751
+ if check.status != "recorded" or check.record is None:
752
+ warnings = (
753
+ [f"round conversation record unreadable: {check.reason}"]
754
+ if check.status == "malformed"
755
+ else []
756
+ )
757
+ return ConversationView(check.status, reason=check.reason), warnings
758
+ return (
759
+ ConversationView(
760
+ "recorded",
761
+ members={
762
+ member_id: MemberConversation(
763
+ session=entry["session"],
764
+ context=entry["context"],
765
+ since_round=entry["since_round"],
766
+ candidates=tuple(entry["candidates"]),
767
+ omitted=entry["omitted"],
768
+ memory=entry["memory"],
769
+ )
770
+ for member_id, entry in check.record["members"].items()
771
+ },
772
+ ),
773
+ [],
774
+ )
775
+
776
+
777
+ def _gate_consistency(
778
+ kind: str, commands: tuple[str, ...], rows: list[CandidateRow]
779
+ ) -> tuple[str, list[str]]:
780
+ """Cross-check a round's recorded gate settings against what its
781
+ completed candidates' reports show. A report that cannot be read makes the
782
+ result ``indeterminate`` — never folded into ``consistent``."""
783
+ contradicted: list[str] = []
784
+ indeterminate: list[str] = []
785
+ for row in rows:
786
+ if row.state != "completed":
787
+ continue
788
+ verdict = row.gate.verdict
789
+ if verdict == "unknown":
790
+ indeterminate.append(row.candidate_id)
791
+ elif kind == "commands" and (
792
+ verdict == "no_report" or row.gate.commands != commands
793
+ ):
794
+ contradicted.append(row.candidate_id)
795
+ elif kind == "none" and verdict in {"passed", "failed"}:
796
+ contradicted.append(row.candidate_id)
797
+ warnings = []
798
+ if contradicted:
799
+ warnings.append(
800
+ "gate reports contradict the recorded gate settings (candidates: "
801
+ + ", ".join(contradicted)
802
+ + ")"
803
+ )
804
+ if indeterminate:
805
+ warnings.append(
806
+ "gate reports could not be checked against the recorded gate settings "
807
+ "(candidates: " + ", ".join(indeterminate) + ")"
808
+ )
809
+ if contradicted:
810
+ return "contradicted", warnings
811
+ if indeterminate:
812
+ return "indeterminate", warnings
813
+ return "consistent", warnings
814
+
815
+
816
+ def _cross_round_settings(groups: list[RoundGroup]) -> list[dict[str, Any]]:
817
+ """Rounds grouped by equal recorded gate settings. Equality is
818
+ established only by two records; a round without a usable one is listed
819
+ alone, never grouped with a recorded round."""
820
+ keyed: dict[tuple[Any, ...], list[RoundGroup]] = {}
821
+ alone: list[dict[str, Any]] = []
822
+ for group in groups:
823
+ settings = group.gate_settings
824
+ if settings.status != "recorded":
825
+ alone.append(
826
+ {
827
+ "rounds": [group.number],
828
+ "settings": "undecidable",
829
+ "commands": [],
830
+ "timeout_seconds": None,
831
+ "reason": (
832
+ "gate settings record unreadable"
833
+ if settings.status == "malformed"
834
+ else "gate settings not recorded"
835
+ ),
836
+ "contradicted_rounds": [],
837
+ "indeterminate_rounds": [],
838
+ }
839
+ )
840
+ continue
841
+ key = (settings.kind, settings.commands, settings.timeout_seconds)
842
+ keyed.setdefault(key, []).append(group)
843
+ entries = [
844
+ {
845
+ "rounds": [g.number for g in members],
846
+ "settings": kind,
847
+ "commands": list(commands),
848
+ "timeout_seconds": timeout,
849
+ "reason": None,
850
+ "contradicted_rounds": [
851
+ g.number for g in members if g.gate_settings.consistency == "contradicted"
852
+ ],
853
+ "indeterminate_rounds": [
854
+ g.number for g in members if g.gate_settings.consistency == "indeterminate"
855
+ ],
856
+ }
857
+ for (kind, commands, timeout), members in keyed.items()
858
+ ]
859
+ return entries + alone
860
+
861
+
862
+ def _overlap(rows: list[CandidateRow]) -> FileOverlap:
863
+ unknown = tuple(r.candidate_id for r in rows if r.files_changed is None)
864
+ no_changes = tuple(r.candidate_id for r in rows if r.files_changed == ())
865
+ # A known-empty file set is part of the denominator: a file is common to
866
+ # all only if every candidate with a known set changed it.
867
+ compared = [r for r in rows if r.files_changed is not None]
868
+ ids = tuple(r.candidate_id for r in compared)
869
+ owners: dict[str, list[str]] = {}
870
+ for row in compared:
871
+ for name in row.files_changed or ():
872
+ owners.setdefault(name, []).append(row.candidate_id)
873
+ if len(compared) < 2:
874
+ common: tuple[str, ...] = ()
875
+ else:
876
+ common = tuple(sorted(n for n, o in owners.items() if len(o) == len(compared)))
877
+ only = {
878
+ row.candidate_id: tuple(
879
+ sorted(n for n in row.files_changed or () if owners[n] == [row.candidate_id])
880
+ )
881
+ for row in compared
882
+ }
883
+ partial = {
884
+ n: tuple(o)
885
+ for n, o in sorted(owners.items())
886
+ if 1 < len(o) < len(compared)
887
+ }
888
+ return FileOverlap(ids, common, only, partial, no_changes, unknown)
889
+
890
+
891
+ def _missing_members(
892
+ store: RoomStore,
893
+ room: dict[str, Any],
894
+ round_row: dict[str, Any],
895
+ rows: list[CandidateRow],
896
+ now: float,
897
+ ) -> tuple[list[MissingMember], list[str]]:
898
+ warnings: list[str] = []
899
+ members = room.get("members")
900
+ expected: list[tuple[str, str]] = []
901
+ if isinstance(members, list):
902
+ for member in members:
903
+ if isinstance(member, dict) and isinstance(member.get("member_id"), str):
904
+ provider = member.get("provider_id")
905
+ expected.append(
906
+ (member["member_id"], provider if isinstance(provider, str) else "?")
907
+ )
908
+ else:
909
+ warnings.append("room member list holds a malformed entry")
910
+ else:
911
+ warnings.append("room member list is malformed")
912
+ progress = {
913
+ str(m["member_id"]): str(m["state"])
914
+ for m in store.list_round_members(str(round_row["id"]))
915
+ }
916
+ have = {r.member_id for r in rows}
917
+ lease = round_row.get("lease_expires")
918
+ lease_live = isinstance(lease, (int, float)) and lease > now
919
+ missing: list[MissingMember] = []
920
+ for member_id, provider_id in expected:
921
+ if member_id in have:
922
+ continue
923
+ member_state = progress.get(member_id)
924
+ if (
925
+ room.get("state") == "running"
926
+ and round_row.get("state") == "running"
927
+ and lease_live
928
+ and (member_state is None or member_state in _IN_PROGRESS_MEMBER_STATES)
929
+ ):
930
+ missing.append(MissingMember(member_id, provider_id, "in_progress", ()))
931
+ continue
932
+ evidence = [f"member state: {member_state or 'not recorded'}"]
933
+ if room.get("state") == "recovery_required":
934
+ evidence.append("room needs recovery")
935
+ if round_row.get("state") == "running" and not lease_live:
936
+ evidence.append("lease expired")
937
+ missing.append(
938
+ MissingMember(member_id, provider_id, "no_candidate", tuple(evidence))
939
+ )
940
+ return missing, warnings
941
+
942
+
943
+ def build_room_comparison(
944
+ store: RoomStore,
945
+ room_id: str,
946
+ *,
947
+ round_number: int | None = None,
948
+ all_rounds: bool = False,
949
+ source_check: SourceCheck | None = None,
950
+ now: float | None = None,
951
+ ) -> RoomComparison:
952
+ """Project one room's candidates into the comparison read model.
953
+
954
+ ``source_check`` returns the live repository fingerprint; any exception it
955
+ raises is reported as ``unavailable`` and never as ``current``. Raises
956
+ :class:`RoomError` for an unknown room or round number.
957
+ """
958
+ if round_number is not None and all_rounds:
959
+ raise ValueError("round_number and all_rounds are mutually exclusive")
960
+ now = time.time() if now is None else now
961
+ room = store.get_room(room_id)
962
+ recorded = room.get("source_fingerprint")
963
+ source_reason: str | None = None
964
+ if not recorded:
965
+ source_status = "not_recorded"
966
+ elif source_check is None:
967
+ source_status, source_reason = "unavailable", "not checked"
968
+ else:
969
+ try:
970
+ source_status = "current" if source_check() == recorded else "changed"
971
+ # Any failure (a non-zero git exit, git missing, an unreadable cwd)
972
+ # means the source is unverified — never "current".
973
+ except Exception as exc:
974
+ source_status = "unavailable"
975
+ source_reason = str(exc) or type(exc).__name__
976
+
977
+ rounds = store.list_rounds(room_id)
978
+ damaged: list[dict[str, str]] = []
979
+ valid: list[dict[str, Any]] = []
980
+ for round_row in rounds:
981
+ if _positive(round_row.get("number")) is None:
982
+ damaged.append(
983
+ {
984
+ "round_id": str(round_row.get("id")),
985
+ "reason": f"malformed round number {round_row.get('number')!r}",
986
+ }
987
+ )
988
+ else:
989
+ valid.append(round_row)
990
+ if round_number is not None:
991
+ chosen = [r for r in valid if r["number"] == round_number]
992
+ if not chosen:
993
+ numbers = ", ".join(str(r["number"]) for r in valid) or "none"
994
+ raise UnknownRoundError(
995
+ f"room {room_id} has no round {round_number} (rounds: {numbers})"
996
+ )
997
+ elif all_rounds:
998
+ chosen = valid
999
+ else:
1000
+ chosen = valid[-1:]
1001
+
1002
+ by_round: dict[str, list[dict[str, Any]]] = {}
1003
+ candidate_rows: dict[str, dict[str, Any]] = {}
1004
+ for candidate in store.list_candidates(room_id):
1005
+ by_round.setdefault(str(candidate["round_id"]), []).append(candidate)
1006
+ candidate_rows[str(candidate["id"])] = candidate
1007
+ members = room.get("members")
1008
+ member_ids = (
1009
+ [m["member_id"] for m in members if isinstance(m, dict) and "member_id" in m]
1010
+ if isinstance(members, list)
1011
+ else []
1012
+ )
1013
+
1014
+ selected_id = room.get("selected_candidate_id")
1015
+ selection: SelectionView | None = None
1016
+ if selected_id:
1017
+ try:
1018
+ selected_row: dict[str, Any] | None = store.get_candidate(selected_id)
1019
+ except RoomError:
1020
+ selected_row = None
1021
+ selection = _selection_view(
1022
+ selected_id,
1023
+ selection_record_status(
1024
+ room.get("selection_json"),
1025
+ selected_candidate_id=selected_id,
1026
+ candidate_row=selected_row,
1027
+ ),
1028
+ )
1029
+ elif room.get("selection_json") is not None:
1030
+ selection = SelectionView(
1031
+ "mismatch", None, "a selection record exists but no candidate is selected"
1032
+ )
1033
+ groups: list[RoundGroup] = []
1034
+ for round_row in chosen:
1035
+ rows = [
1036
+ project_candidate(
1037
+ store,
1038
+ candidate,
1039
+ room=room,
1040
+ source_status=source_status,
1041
+ source_reason=source_reason,
1042
+ )
1043
+ for candidate in by_round.get(str(round_row["id"]), [])
1044
+ ]
1045
+ try:
1046
+ profile, warnings = _gate_profile(rows)
1047
+ lineage, settings, provenance_warnings = _provenance_views(
1048
+ round_provenance_status(
1049
+ round_row.get("provenance_json"),
1050
+ round_number=int(round_row["number"]),
1051
+ member_ids=member_ids,
1052
+ candidates=candidate_rows,
1053
+ ),
1054
+ rows,
1055
+ )
1056
+ conversation, conversation_warnings = _conversation_view(
1057
+ round_conversation_status(
1058
+ round_row.get("conversation_json"),
1059
+ round_number=int(round_row["number"]),
1060
+ members=members if isinstance(members, list) else [],
1061
+ candidates=candidate_rows,
1062
+ )
1063
+ )
1064
+ missing, member_warnings = _missing_members(
1065
+ store, room, round_row, rows, now
1066
+ )
1067
+ groups.append(
1068
+ RoundGroup(
1069
+ number=int(round_row["number"]),
1070
+ round_id=str(round_row["id"]),
1071
+ state=str(round_row["state"]),
1072
+ starting_tree=(
1073
+ "snapshot" if round_row["number"] == 1 else "not_recorded"
1074
+ ),
1075
+ gate_profile=profile,
1076
+ rows=tuple(rows),
1077
+ missing=tuple(missing),
1078
+ overlap=_overlap(rows),
1079
+ warnings=tuple(
1080
+ warnings + provenance_warnings + conversation_warnings + member_warnings
1081
+ ),
1082
+ lineage=lineage,
1083
+ gate_settings=settings,
1084
+ conversation=conversation,
1085
+ )
1086
+ )
1087
+ except _ROW_ERRORS as exc:
1088
+ damaged.append(
1089
+ {
1090
+ "round_id": str(round_row.get("id")),
1091
+ "reason": str(exc) or type(exc).__name__,
1092
+ }
1093
+ )
1094
+
1095
+ cross: list[dict[str, Any]] = []
1096
+ cross_settings: list[dict[str, Any]] = []
1097
+ if all_rounds and len(groups) > 1:
1098
+ cross_settings = _cross_round_settings(groups)
1099
+ # Rounds are grouped by equality of their recorded gate commands (an
1100
+ # equivalence relation, so the groups carry the same information as
1101
+ # every pairwise answer). Only recorded commands establish equality: a
1102
+ # round with no gate report, an unknown or a mixed profile says nothing
1103
+ # about its settings and is listed on its own with the reason.
1104
+ by_commands: dict[tuple[str, ...], list[int]] = {}
1105
+ for group in groups:
1106
+ if group.gate_profile.kind == "commands":
1107
+ by_commands.setdefault(group.gate_profile.commands, []).append(
1108
+ group.number
1109
+ )
1110
+ for commands, numbers in by_commands.items():
1111
+ cross.append(
1112
+ {
1113
+ "rounds": numbers,
1114
+ "gate_profile": "commands",
1115
+ "commands": list(commands),
1116
+ "reason": None,
1117
+ }
1118
+ )
1119
+ for group in groups:
1120
+ if group.gate_profile.kind != "commands":
1121
+ cross.append(
1122
+ {
1123
+ "rounds": [group.number],
1124
+ "gate_profile": "undecidable",
1125
+ "commands": [],
1126
+ "reason": _UNDECIDABLE_REASON.get(
1127
+ group.gate_profile.kind, group.gate_profile.kind
1128
+ ),
1129
+ }
1130
+ )
1131
+
1132
+ return RoomComparison(
1133
+ room_id=room_id,
1134
+ room_state=str(room["state"]),
1135
+ revision=int(room["revision"]),
1136
+ selected_candidate_id=selected_id,
1137
+ source_status=source_status,
1138
+ source_reason=source_reason,
1139
+ round_count=len(rounds),
1140
+ groups=tuple(groups),
1141
+ cross_round=tuple(cross),
1142
+ cross_round_settings=tuple(cross_settings),
1143
+ damaged_rounds=tuple(damaged),
1144
+ selection=selection,
1145
+ )
1146
+
1147
+
1148
+ # --------------------------------------------------------------------- JSON
1149
+
1150
+
1151
+ def _usage_dict(usage: TokenUsage | None) -> dict[str, Any] | None:
1152
+ if usage is None:
1153
+ return None
1154
+ return {
1155
+ "total_input_tokens": usage.total_input_tokens,
1156
+ "output_tokens": usage.output_tokens,
1157
+ "cache_read_tokens": usage.cache_read_tokens,
1158
+ "cache_creation_tokens": usage.cache_creation_tokens,
1159
+ "total_cost_usd": usage.total_cost_usd,
1160
+ }
1161
+
1162
+
1163
+ def _row_dict(row: CandidateRow) -> dict[str, Any]:
1164
+ return {
1165
+ "candidate_id": row.candidate_id,
1166
+ "member_id": row.member_id,
1167
+ "provider_id": row.provider_id,
1168
+ "attempt": row.attempt,
1169
+ "state": row.state,
1170
+ "eligible": row.eligible,
1171
+ "not_eligible_reason": row.not_eligible_reason,
1172
+ "selected": row.selected,
1173
+ "base_commit": row.base_commit,
1174
+ "readable": row.readable,
1175
+ "unreadable_reason": row.unreadable_reason,
1176
+ "gate": {
1177
+ "verdict": row.gate.verdict,
1178
+ "reason": row.gate.reason,
1179
+ "checks": [
1180
+ {
1181
+ "command": c.command,
1182
+ "passed": c.passed,
1183
+ "exit_code": c.exit_code,
1184
+ "duration_seconds": c.duration_seconds,
1185
+ }
1186
+ for c in row.gate.checks
1187
+ ],
1188
+ },
1189
+ "duration_seconds": row.duration_seconds,
1190
+ "usage": _usage_dict(row.usage),
1191
+ "files_changed": (
1192
+ None if row.files_changed is None else list(row.files_changed)
1193
+ ),
1194
+ "patch": {
1195
+ "sha256": row.patch_sha256,
1196
+ "bytes": row.patch_bytes,
1197
+ "binary": row.patch_binary,
1198
+ "large": row.patch_large,
1199
+ },
1200
+ "apply_blockers": list(row.apply_blockers),
1201
+ "warnings": list(row.warnings),
1202
+ "created_at": row.created_at,
1203
+ "exit_code": row.exit_code,
1204
+ "output": {
1205
+ "sha256": row.output_sha256,
1206
+ "bytes": row.output_bytes,
1207
+ "truncated": row.output_truncated,
1208
+ },
1209
+ "provider_session_id": row.provider_session_id,
1210
+ }
1211
+
1212
+
1213
+ def _selection_dict(selection: SelectionView | None) -> dict[str, Any] | None:
1214
+ if selection is None:
1215
+ return None
1216
+ return {
1217
+ "status": selection.status,
1218
+ "candidate_id": selection.candidate_id,
1219
+ "reason": selection.reason,
1220
+ "patch_sha256": selection.patch_sha256,
1221
+ "confirmed_revision": selection.confirmed_revision,
1222
+ "revision": selection.revision,
1223
+ "rationale": selection.rationale,
1224
+ "surface": selection.surface,
1225
+ "selected_at": selection.selected_at,
1226
+ }
1227
+
1228
+
1229
+ def _lineage_dict(lineage: LineageView) -> dict[str, Any]:
1230
+ return {
1231
+ "status": lineage.status,
1232
+ "reason": lineage.reason,
1233
+ "requested": lineage.requested,
1234
+ "tree": lineage.tree,
1235
+ "base_candidate_id": lineage.base_candidate_id,
1236
+ "base_patch_sha256": lineage.base_patch_sha256,
1237
+ "base_round_number": lineage.base_round_number,
1238
+ "members": {
1239
+ member_id: (
1240
+ None
1241
+ if parent is None
1242
+ else {"parent_candidate_id": parent[0], "parent_round_number": parent[1]}
1243
+ )
1244
+ for member_id, parent in lineage.parents.items()
1245
+ },
1246
+ }
1247
+
1248
+
1249
+ def _gate_settings_dict(settings: GateSettingsView) -> dict[str, Any]:
1250
+ return {
1251
+ "status": settings.status,
1252
+ "kind": settings.kind,
1253
+ "commands": list(settings.commands),
1254
+ "timeout_seconds": settings.timeout_seconds,
1255
+ "consistency": settings.consistency,
1256
+ }
1257
+
1258
+
1259
+ def _member_conversation_dict(entry: MemberConversation | None) -> dict[str, Any] | None:
1260
+ if entry is None:
1261
+ return None
1262
+ return {
1263
+ "session": entry.session,
1264
+ "context": entry.context,
1265
+ "since_round": entry.since_round,
1266
+ "candidates": list(entry.candidates),
1267
+ "omitted": entry.omitted,
1268
+ "memory": entry.memory,
1269
+ }
1270
+
1271
+
1272
+ def _conversation_dict(view: ConversationView) -> dict[str, Any]:
1273
+ return {
1274
+ "status": view.status,
1275
+ "reason": view.reason,
1276
+ "members": {
1277
+ member_id: _member_conversation_dict(entry)
1278
+ for member_id, entry in view.members.items()
1279
+ },
1280
+ }
1281
+
1282
+
1283
+ def starts_from(group: RoundGroup, row: CandidateRow) -> dict[str, Any] | None:
1284
+ """Where one candidate's worktree started, from the round's record, or
1285
+ None when the round has no usable record. Differs between the candidates
1286
+ of a round only when the round continued each member's own worktree."""
1287
+ lineage = group.lineage
1288
+ if lineage.status != "recorded":
1289
+ return None
1290
+ if lineage.tree == "continued":
1291
+ parent = lineage.parents.get(row.member_id)
1292
+ return {
1293
+ "kind": "continued",
1294
+ "parent_candidate_id": None if parent is None else parent[0],
1295
+ "parent_round_number": None if parent is None else parent[1],
1296
+ }
1297
+ if lineage.tree == "branch":
1298
+ return {
1299
+ "kind": "branch",
1300
+ "base_candidate_id": lineage.base_candidate_id,
1301
+ "base_patch_sha256": lineage.base_patch_sha256,
1302
+ }
1303
+ return {"kind": "snapshot"}
1304
+
1305
+
1306
+ def comparison_to_dict(cmp: RoomComparison) -> dict[str, Any]:
1307
+ """The versioned JSON form. Additive fields keep ``schema_version`` 1;
1308
+ removing, renaming or re-typing a field bumps it. Body-free: ids, patch
1309
+ digests, sizes, verdicts, file names and gate commands — never prompt,
1310
+ output, patch or gate-check output text. The one free-text field is the
1311
+ selection rationale, which is the human reviewer's own words."""
1312
+ return {
1313
+ "schema_version": SCHEMA_VERSION,
1314
+ "kind": KIND,
1315
+ "room": {
1316
+ "room_id": cmp.room_id,
1317
+ "state": cmp.room_state,
1318
+ "revision": cmp.revision,
1319
+ "selected_candidate_id": cmp.selected_candidate_id,
1320
+ "round_count": cmp.round_count,
1321
+ "selection": _selection_dict(cmp.selection),
1322
+ },
1323
+ "source": {"status": cmp.source_status, "reason": cmp.source_reason},
1324
+ "rounds": [
1325
+ {
1326
+ "number": g.number,
1327
+ "round_id": g.round_id,
1328
+ "state": g.state,
1329
+ "starting_tree": g.starting_tree,
1330
+ "gate_profile": {
1331
+ "kind": g.gate_profile.kind,
1332
+ "commands": list(g.gate_profile.commands),
1333
+ },
1334
+ "candidates": [
1335
+ {
1336
+ **_row_dict(r),
1337
+ "starts_from": starts_from(g, r),
1338
+ "conversation": _member_conversation_dict(
1339
+ g.conversation.members.get(r.member_id)
1340
+ ),
1341
+ }
1342
+ for r in g.rows
1343
+ ],
1344
+ "missing_members": [
1345
+ {
1346
+ "member_id": m.member_id,
1347
+ "provider_id": m.provider_id,
1348
+ "status": m.status,
1349
+ "evidence": list(m.evidence),
1350
+ }
1351
+ for m in g.missing
1352
+ ],
1353
+ "files": {
1354
+ "compared": list(g.overlap.compared),
1355
+ "common": list(g.overlap.common),
1356
+ "only": {k: list(v) for k, v in g.overlap.only.items()},
1357
+ "partial": {k: list(v) for k, v in g.overlap.partial.items()},
1358
+ "no_changes": list(g.overlap.no_changes),
1359
+ "unknown": list(g.overlap.unknown),
1360
+ },
1361
+ "warnings": list(g.warnings),
1362
+ "lineage": _lineage_dict(g.lineage),
1363
+ "gate_settings": _gate_settings_dict(g.gate_settings),
1364
+ "conversation": _conversation_dict(g.conversation),
1365
+ }
1366
+ for g in cmp.groups
1367
+ ],
1368
+ "cross_round": [dict(entry) for entry in cmp.cross_round],
1369
+ "cross_round_settings": [dict(entry) for entry in cmp.cross_round_settings],
1370
+ "damaged_rounds": [dict(entry) for entry in cmp.damaged_rounds],
1371
+ }
1372
+
1373
+
1374
+ # --------------------------------------------------------------------- text
1375
+
1376
+
1377
+ def _p(value: object) -> str:
1378
+ """Every stored string is untrusted display data: escape controls and line
1379
+ breaks so nothing can drive the terminal or forge a line."""
1380
+ return printable_name(str(value))
1381
+
1382
+
1383
+ _GATE_TEXT = {
1384
+ "passed": "passed",
1385
+ "failed": "failed",
1386
+ "not_run": "not run",
1387
+ "no_report": "no gate report",
1388
+ "unknown": "unknown",
1389
+ }
1390
+
1391
+ _SOURCE_TEXT = {
1392
+ "current": "unchanged since the room started",
1393
+ "changed": "CHANGED since the room started — apply would be rejected",
1394
+ "not_recorded": "not recorded (no round has run)",
1395
+ }
1396
+
1397
+
1398
+ def gate_label(gate: GateView) -> str:
1399
+ """The one wording of a gate verdict, shared by every surface."""
1400
+ text = _GATE_TEXT.get(gate.verdict, gate.verdict)
1401
+ if gate.reason:
1402
+ text += f" ({_p(gate.reason)})"
1403
+ return text
1404
+
1405
+
1406
+ def usage_label(usage: TokenUsage | None) -> str:
1407
+ """The one long-form wording of reported usage; ``not reported`` when the
1408
+ provider sent none (never 0)."""
1409
+ return _usage_text(usage)
1410
+
1411
+
1412
+ def _usage_text(usage: TokenUsage | None) -> str:
1413
+ if usage is None:
1414
+ return "not reported"
1415
+ parts = []
1416
+ if usage.total_input_tokens is not None:
1417
+ parts.append(f"input {usage.total_input_tokens:,}")
1418
+ if usage.output_tokens is not None:
1419
+ parts.append(f"output {usage.output_tokens:,}")
1420
+ if usage.cache_read_tokens is not None:
1421
+ parts.append(f"cache read {usage.cache_read_tokens:,}")
1422
+ if usage.cache_creation_tokens is not None:
1423
+ parts.append(f"cache write {usage.cache_creation_tokens:,}")
1424
+ if usage.total_cost_usd is not None:
1425
+ parts.append(f"cost ${usage.total_cost_usd:.4f}")
1426
+ return ", ".join(parts) if parts else "not reported"
1427
+
1428
+
1429
+ _SURFACE_TEXT = {"cli": "CLI", "tui": "TUI", "api": "API"}
1430
+
1431
+ _UNREADABLE_RECORD = "unknown (round provenance record unreadable, see warnings)"
1432
+
1433
+
1434
+ def starting_tree_label(group: RoundGroup) -> str:
1435
+ """The one wording of where a round's candidates started, shared by every
1436
+ surface. It names ids and digests but no free text."""
1437
+ lineage = group.lineage
1438
+ if lineage.status == "malformed":
1439
+ return _UNREADABLE_RECORD
1440
+ if lineage.status != "recorded":
1441
+ # Round 1 can only snapshot (branch-from needs an earlier eligible
1442
+ # candidate; continue-own has no workspaces yet).
1443
+ if group.number == 1:
1444
+ return "snapshot"
1445
+ return "not recorded (this round predates lineage records)"
1446
+ requested = f" (requested lineage: {_p(lineage.requested)})"
1447
+ if lineage.tree == "branch":
1448
+ return (
1449
+ f"snapshot plus candidate {_p(lineage.base_candidate_id)}'s patch "
1450
+ f"(round {lineage.base_round_number}, sha256 {_p(lineage.base_patch_sha256)}), "
1451
+ "seeded into every member" + requested
1452
+ )
1453
+ if lineage.tree == "continued":
1454
+ return (
1455
+ "each member continues its own retained worktree; "
1456
+ "a common starting tree is not established" + requested
1457
+ )
1458
+ return "snapshot" + requested
1459
+
1460
+
1461
+ def row_start_label(group: RoundGroup, row: CandidateRow) -> str | None:
1462
+ """Where one candidate's worktree started, when that differs per candidate
1463
+ (a round that continued each member's own worktree); else None."""
1464
+ if group.lineage.status != "recorded" or group.lineage.tree != "continued":
1465
+ return None
1466
+ parent = group.lineage.parents.get(row.member_id)
1467
+ if parent is None:
1468
+ return "parent not recorded"
1469
+ return f"the worktree left by candidate {_p(parent[0])} (round {parent[1]})"
1470
+
1471
+
1472
+ def gate_settings_label(settings: GateSettingsView) -> str:
1473
+ """The one wording of a round's recorded gate settings. It never includes
1474
+ the commands themselves (free text each surface places its own way)."""
1475
+ if settings.status == "malformed":
1476
+ return _UNREADABLE_RECORD
1477
+ if settings.status != "recorded":
1478
+ return "not recorded (timeout not recorded)"
1479
+ if settings.kind == "none":
1480
+ text = "none configured"
1481
+ else:
1482
+ text = f"commands, timeout {settings.timeout_seconds}s"
1483
+ if settings.consistency == "contradicted":
1484
+ text += "; gate reports CONTRADICT these settings (see warnings)"
1485
+ elif settings.consistency == "indeterminate":
1486
+ text += "; some gate reports could not be checked (see warnings)"
1487
+ return text
1488
+
1489
+
1490
+ _SESSION_TEXT = {
1491
+ "cold": "cold session",
1492
+ "resume": "resume of its stored session requested",
1493
+ "fork": "fork of its stored session requested",
1494
+ }
1495
+ _MEMORY_TEXT = {
1496
+ "included": "memory context included",
1497
+ "dropped": "memory context dropped (prompt size limit)",
1498
+ "none": "no memory context found",
1499
+ }
1500
+
1501
+
1502
+ def member_conversation(group: RoundGroup, row: CandidateRow) -> MemberConversation | None:
1503
+ if group.conversation.status != "recorded":
1504
+ return None
1505
+ return group.conversation.members.get(row.member_id)
1506
+
1507
+
1508
+ def conversation_label(group: RoundGroup, row: CandidateRow) -> str | None:
1509
+ """The one wording of the conversation one candidate's provider was
1510
+ handed, or None for a round without a usable record (its round label
1511
+ says why). Counts only; the ids are in the JSON and the Details."""
1512
+ entry = member_conversation(group, row)
1513
+ if entry is None:
1514
+ return None
1515
+ count = len(entry.candidates)
1516
+ excerpts = f"{count} candidate excerpt{'' if count == 1 else 's'}"
1517
+ if entry.context == "delta":
1518
+ checkpoint = f"peers' outputs since round {entry.since_round}: {excerpts}"
1519
+ elif count or entry.omitted:
1520
+ checkpoint = f"full checkpoint: {excerpts}"
1521
+ else:
1522
+ checkpoint = "no earlier candidates"
1523
+ if entry.omitted:
1524
+ checkpoint += (
1525
+ ", older ones omitted for size" if count else " (all omitted for size)"
1526
+ )
1527
+ return f"{_SESSION_TEXT[entry.session]}; {checkpoint}; {_MEMORY_TEXT[entry.memory]}"
1528
+
1529
+
1530
+ def round_conversation_label(group: RoundGroup) -> str:
1531
+ """Counts of the recorded modes, never a claim that members shared one
1532
+ context: each member has its own provider conversation, and two delta
1533
+ checkpoints exclude different members' candidates."""
1534
+ view = group.conversation
1535
+ if view.status == "malformed":
1536
+ return "unknown (round conversation record unreadable, see warnings)"
1537
+ if view.status != "recorded":
1538
+ # NULL is a round from before these records, or one whose record write
1539
+ # failed and stopped it before any provider ran — never assert which.
1540
+ return "not recorded (an older round, or one that stopped before any provider ran)"
1541
+ entries = list(view.members.values())
1542
+
1543
+ def counts(values: list[str], order: tuple[str, ...]) -> str:
1544
+ return ", ".join(f"{v} {values.count(v)}" for v in order if v in values)
1545
+
1546
+ memory = [e.memory for e in entries]
1547
+ memory_text = (
1548
+ "none found"
1549
+ if set(memory) == {"none"}
1550
+ else counts(memory, ("included", "dropped"))
1551
+ )
1552
+ return (
1553
+ "each member has its own; sessions: "
1554
+ + counts([e.session for e in entries], ("resume", "fork", "cold"))
1555
+ + f"; memory context: {memory_text}"
1556
+ + f"; checkpoint excerpts are capped at {_ROOM_CANDIDATE_CONTEXT_MAX_BYTES:,} bytes each"
1557
+ )
1558
+
1559
+
1560
+ def _timeout_note(group: RoundGroup) -> bool:
1561
+ """Whether the report-derived gate profile still needs its ``timeout not
1562
+ recorded`` qualification (no usable settings record for the round)."""
1563
+ return group.gate_profile.kind == "commands" and group.gate_settings.status != "recorded"
1564
+
1565
+
1566
+ def selection_lines(selection: SelectionView | None) -> list[str]:
1567
+ """The selection block every text surface shows (``room compare``, the TUI
1568
+ Compare view and the selected candidate's Details)."""
1569
+ if selection is None:
1570
+ return ["Selected: none"]
1571
+ who = _p(selection.candidate_id) if selection.candidate_id else "none"
1572
+ if selection.status == "not_recorded":
1573
+ return [
1574
+ f"Selected: {who} (human)",
1575
+ " selection record: not recorded (selected before records were kept)",
1576
+ ]
1577
+ if selection.status != "bound":
1578
+ return [
1579
+ f"Selected: {who} (human)",
1580
+ f" selection record: INCONSISTENT — {_p(selection.reason)}",
1581
+ ]
1582
+ confirmed = (
1583
+ f"revision {selection.confirmed_revision} confirmed"
1584
+ if selection.confirmed_revision is not None
1585
+ else "no revision confirmed (selected against the state at that moment)"
1586
+ )
1587
+ lines = [
1588
+ f"Selected: {who} (human)",
1589
+ f" patch sha256: {_p(selection.patch_sha256)}",
1590
+ f" {confirmed}; recorded as revision {selection.revision} via "
1591
+ f"{_SURFACE_TEXT.get(selection.surface or '', _p(selection.surface))} "
1592
+ f"at {_p(selection.selected_at)}",
1593
+ ]
1594
+ if selection.rationale is None:
1595
+ lines.append(" rationale: none recorded")
1596
+ else:
1597
+ lines.append(" rationale:")
1598
+ lines.extend(f" {_p(line)}" for line in selection.rationale.split("\n"))
1599
+ return lines
1600
+
1601
+
1602
+ def _row_lines(row: CandidateRow, group: RoundGroup) -> list[str]:
1603
+ attempt = "?" if row.attempt is None else row.attempt
1604
+ head = f" {_p(row.candidate_id)} {_p(row.member_id)} ({_p(row.provider_id)}) #{attempt} {_p(row.state)}"
1605
+ if row.selected:
1606
+ head += " [selected (human)]"
1607
+ lines = [head]
1608
+ if (start := row_start_label(group, row)) is not None:
1609
+ lines.append(f" starts from: {start}")
1610
+ if (conversation := conversation_label(group, row)) is not None:
1611
+ lines.append(f" conversation: {conversation}")
1612
+ if not row.readable:
1613
+ lines.append(f" unreadable: {_p(row.unreadable_reason)}")
1614
+ lines.append(f" gate: {gate_label(row.gate)}")
1615
+ for check in row.gate.checks:
1616
+ lines.append(f" {'✓' if check.passed else '×'} {_p(check.command)}")
1617
+ duration = (
1618
+ "not recorded" if row.duration_seconds is None else f"{row.duration_seconds:.1f}s"
1619
+ )
1620
+ lines.append(f" duration: {duration} | usage: {_usage_text(row.usage)}")
1621
+ if row.patch_bytes is None:
1622
+ patch = "unknown"
1623
+ else:
1624
+ patch = f"{row.patch_bytes:,} bytes"
1625
+ if row.patch_binary:
1626
+ patch += ", binary"
1627
+ if row.patch_large:
1628
+ patch += ", large"
1629
+ files = (
1630
+ "unknown" if row.files_changed is None else str(len(row.files_changed))
1631
+ )
1632
+ lines.append(f" patch: {patch} | files: {files} | base: {_p(row.base_commit)}")
1633
+ if row.eligible:
1634
+ lines.append(" eligible: yes")
1635
+ elif row.not_eligible_reason:
1636
+ lines.append(f" eligible: no ({_p(row.not_eligible_reason)})")
1637
+ else:
1638
+ lines.append(" eligible: no")
1639
+ if row.apply_blockers:
1640
+ lines.append(" apply blockers:")
1641
+ lines.extend(f" - {_p(b)}" for b in row.apply_blockers)
1642
+ else:
1643
+ lines.append(
1644
+ " apply blockers: none known (select/apply still revalidate)"
1645
+ )
1646
+ for warning in row.warnings:
1647
+ lines.append(f" warning: {_p(warning)}")
1648
+ if row.readable:
1649
+ lines.append(f" diff: syncmill room diff {_p(row.candidate_id)}")
1650
+ return lines
1651
+
1652
+
1653
+ def _profile_text(profile: GateProfile) -> str:
1654
+ if profile.kind == "commands":
1655
+ return "commands: " + "; ".join(_p(c) for c in profile.commands)
1656
+ return {
1657
+ "no_report": "no gate report on any completed candidate",
1658
+ "unknown": "unknown (no completed candidate with a usable record)",
1659
+ "mixed": "mixed (records disagree — see warnings)",
1660
+ }.get(profile.kind, profile.kind)
1661
+
1662
+
1663
+ def _overlap_lines(overlap: FileOverlap) -> list[str]:
1664
+ lines = [" files:"]
1665
+ if len(overlap.compared) >= 2:
1666
+ lines.append(
1667
+ f" common to all ({len(overlap.common)}): "
1668
+ + (", ".join(_p(n) for n in overlap.common) or "-")
1669
+ )
1670
+ for candidate_id, names in overlap.only.items():
1671
+ lines.append(
1672
+ f" only {_p(candidate_id)} ({len(names)}): "
1673
+ + (", ".join(_p(n) for n in names) or "-")
1674
+ )
1675
+ if overlap.partial:
1676
+ lines.append(" changed by some, not all:")
1677
+ for name, owners in overlap.partial.items():
1678
+ lines.append(f" {_p(name)}: {', '.join(_p(o) for o in owners)}")
1679
+ if overlap.no_changes:
1680
+ lines.append(
1681
+ " no files changed: " + ", ".join(_p(c) for c in overlap.no_changes)
1682
+ )
1683
+ if overlap.unknown:
1684
+ lines.append(
1685
+ " file set unknown: " + ", ".join(_p(c) for c in overlap.unknown)
1686
+ )
1687
+ return lines
1688
+
1689
+
1690
+ def render_room_comparison(cmp: RoomComparison) -> str:
1691
+ source = _SOURCE_TEXT.get(cmp.source_status)
1692
+ if source is None:
1693
+ source = f"check unavailable ({_p(cmp.source_reason)})"
1694
+ lines = [
1695
+ f"Room {_p(cmp.room_id)} state: {_p(cmp.room_state)} revision: {cmp.revision}",
1696
+ f"Source: {source}",
1697
+ *selection_lines(cmp.selection),
1698
+ ]
1699
+ for entry in cmp.damaged_rounds:
1700
+ lines.append(
1701
+ f"Damaged round {_p(entry['round_id'])}: {_p(entry['reason'])}"
1702
+ )
1703
+ if not cmp.groups:
1704
+ lines.append("")
1705
+ lines.append(
1706
+ "No readable rounds in this room."
1707
+ if cmp.damaged_rounds
1708
+ else "No rounds have run in this room yet."
1709
+ )
1710
+ return "\n".join(lines)
1711
+ for group in cmp.groups:
1712
+ settings = group.gate_settings
1713
+ lines.extend(
1714
+ [
1715
+ "",
1716
+ f"Round {group.number} of {cmp.round_count} ({_p(group.state)}) — "
1717
+ f"starting tree: {starting_tree_label(group)}",
1718
+ f" gate settings: {gate_settings_label(settings)}"
1719
+ + (
1720
+ ": " + "; ".join(_p(c) for c in settings.commands)
1721
+ if settings.status == "recorded" and settings.kind == "commands"
1722
+ else ""
1723
+ ),
1724
+ f" gate reports: {_profile_text(group.gate_profile)}"
1725
+ + ("; timeout not recorded" if _timeout_note(group) else ""),
1726
+ f" conversations: {round_conversation_label(group)}",
1727
+ ]
1728
+ )
1729
+ if not group.rows and not group.missing:
1730
+ lines.append(" (no candidates)")
1731
+ for row in group.rows:
1732
+ lines.extend(_row_lines(row, group))
1733
+ for member in group.missing:
1734
+ if member.status == "in_progress":
1735
+ lines.append(f" {_p(member.member_id)} ({_p(member.provider_id)}): in progress")
1736
+ else:
1737
+ lines.append(
1738
+ f" {_p(member.member_id)} ({_p(member.provider_id)}): no candidate retained ("
1739
+ + "; ".join(_p(e) for e in member.evidence)
1740
+ + ")"
1741
+ )
1742
+ if group.rows:
1743
+ lines.extend(_overlap_lines(group.overlap))
1744
+ for warning in group.warnings:
1745
+ lines.append(f" warning: {_p(warning)}")
1746
+ if cmp.cross_round or cmp.cross_round_settings:
1747
+ lines.extend(["", "Across rounds:"])
1748
+ lines.extend(f" {line}" for line in cross_round_lines(cmp, _p))
1749
+ return "\n".join(lines)
1750
+
1751
+
1752
+ def cross_round_lines(cmp: RoomComparison, esc: Callable[[object], str]) -> list[str]:
1753
+ """The cross-round summary every surface shows: recorded gate settings,
1754
+ report-observed commands, and each round's starting tree. ``esc`` escapes
1755
+ the free-text parts (commands, reasons) for the target surface."""
1756
+ lines: list[str] = []
1757
+ if cmp.cross_round_settings:
1758
+ lines.append("recorded gate settings:")
1759
+ for entry in cmp.cross_round_settings:
1760
+ rounds = ", ".join(str(n) for n in entry["rounds"])
1761
+ if entry["settings"] == "undecidable":
1762
+ lines.append(f" round {rounds}: cannot establish equality ({esc(entry['reason'])})")
1763
+ continue
1764
+ what = (
1765
+ "no gate configured"
1766
+ if entry["settings"] == "none"
1767
+ else f"timeout {entry['timeout_seconds']}s, commands: "
1768
+ + "; ".join(esc(c) for c in entry["commands"])
1769
+ )
1770
+ line = f" rounds {rounds}: same settings: {what}"
1771
+ notes = []
1772
+ if entry["contradicted_rounds"]:
1773
+ notes.append(
1774
+ "reports contradict them in round "
1775
+ + ", ".join(str(n) for n in entry["contradicted_rounds"])
1776
+ )
1777
+ if entry["indeterminate_rounds"]:
1778
+ notes.append(
1779
+ "reports could not all be checked in round "
1780
+ + ", ".join(str(n) for n in entry["indeterminate_rounds"])
1781
+ )
1782
+ if notes:
1783
+ line += " (" + "; ".join(notes) + ")"
1784
+ lines.append(line)
1785
+ if cmp.cross_round:
1786
+ lines.append("gate commands observed in reports (reports carry no timeout):")
1787
+ for entry in cmp.cross_round:
1788
+ rounds = ", ".join(str(n) for n in entry["rounds"])
1789
+ if entry["gate_profile"] == "undecidable":
1790
+ lines.append(f" round {rounds}: cannot establish equality ({esc(entry['reason'])})")
1791
+ else:
1792
+ lines.append(
1793
+ f" rounds {rounds}: same commands: "
1794
+ + "; ".join(esc(c) for c in entry["commands"])
1795
+ )
1796
+ lines.append("starting tree:")
1797
+ lines.extend(f" round {g.number}: {starting_tree_label(g)}" for g in cmp.groups)
1798
+ return lines
1799
+
1800
+
1801
+ # ------------------------------------------------------------ compact table
1802
+
1803
+ #: Rendered file names per overlap section in the compact table; a large patch
1804
+ #: set must not turn one render into an unbounded string build.
1805
+ TABLE_FILE_CAP = 200
1806
+
1807
+
1808
+ def _clip(value: object, width: int) -> str:
1809
+ text = _p(value)
1810
+ return text if len(text) <= width else text[: width - 1] + "…"
1811
+
1812
+
1813
+ def _size(n: int | None) -> str:
1814
+ if n is None:
1815
+ return "?"
1816
+ if n < 1024:
1817
+ return f"{n} B"
1818
+ if n < 1024 * 1024:
1819
+ return f"{n / 1024:.1f} KB"
1820
+ return f"{n / (1024 * 1024):.1f} MB"
1821
+
1822
+
1823
+ def _usage_short(usage: TokenUsage | None) -> str:
1824
+ if usage is None:
1825
+ return "not reported"
1826
+
1827
+ def k(n: int) -> str:
1828
+ # Integer arithmetic: a huge (but valid) count from a corrupt manifest
1829
+ # must not overflow a float division.
1830
+ return f"{n // 1000}.{n % 1000 // 100}k" if n >= 1000 else str(n)
1831
+
1832
+ parts = []
1833
+ if usage.total_input_tokens is not None:
1834
+ parts.append(f"in {k(usage.total_input_tokens)}")
1835
+ if usage.output_tokens is not None:
1836
+ parts.append(f"out {k(usage.output_tokens)}")
1837
+ if usage.total_cost_usd is not None:
1838
+ parts.append(f"${usage.total_cost_usd:.2f}")
1839
+ if not parts and usage.cache_read_tokens is not None:
1840
+ parts.append(f"cache {k(usage.cache_read_tokens)}")
1841
+ if not parts and usage.cache_creation_tokens is not None:
1842
+ parts.append(f"cache write {k(usage.cache_creation_tokens)}")
1843
+ return " ".join(parts) or "not reported"
1844
+
1845
+
1846
+ def _capped(names: tuple[str, ...]) -> str:
1847
+ shown = ", ".join(_p(n) for n in names[:TABLE_FILE_CAP]) or "-"
1848
+ if len(names) > TABLE_FILE_CAP:
1849
+ shown += f", ... {len(names) - TABLE_FILE_CAP} more"
1850
+ return shown
1851
+
1852
+
1853
+ def find_round(cmp: RoomComparison, round_number: int) -> RoundGroup | None:
1854
+ for group in cmp.groups:
1855
+ if group.number == round_number:
1856
+ return group
1857
+ return None
1858
+
1859
+
1860
+ def find_row(cmp: RoomComparison, candidate_id: str) -> tuple[RoundGroup, CandidateRow] | None:
1861
+ for group in cmp.groups:
1862
+ for row in group.rows:
1863
+ if row.candidate_id == candidate_id:
1864
+ return group, row
1865
+ return None
1866
+
1867
+
1868
+ def render_round_table(
1869
+ cmp: RoomComparison,
1870
+ round_number: int,
1871
+ *,
1872
+ highlighted_id: str | None = None,
1873
+ as_of: str | None = None,
1874
+ ) -> str:
1875
+ """One round of a comparison as a compact, width-bounded table for the TUI
1876
+ Compare view. The highlighted (UI cursor) row is marked ``>``; the
1877
+ persisted human choice is marked ``selected`` — they are different things."""
1878
+ group = find_round(cmp, round_number)
1879
+ source = _SOURCE_TEXT.get(cmp.source_status)
1880
+ if source is None:
1881
+ source = f"check unavailable ({_p(cmp.source_reason)})"
1882
+ lines = [f"[Compare] room {_p(cmp.room_id[:8])} ({_p(cmp.room_state)})"]
1883
+ for entry in cmp.damaged_rounds:
1884
+ lines.append(f"Damaged round {_p(entry['round_id'])}: {_p(entry['reason'])}")
1885
+ if group is None:
1886
+ lines.append(f"Round {round_number} is not in this comparison.")
1887
+ return "\n".join(lines)
1888
+ settings = group.gate_settings
1889
+ lines.extend(
1890
+ [
1891
+ f"Round {group.number} of {cmp.round_count} ({_p(group.state)})",
1892
+ f"starting tree: {starting_tree_label(group)}",
1893
+ f"gate settings: {gate_settings_label(settings)}"
1894
+ + (
1895
+ ": " + "; ".join(_p(c) for c in settings.commands)
1896
+ if settings.status == "recorded" and settings.kind == "commands"
1897
+ else ""
1898
+ ),
1899
+ f"gate reports: {_profile_text(group.gate_profile)}"
1900
+ + (" (timeout not recorded)" if _timeout_note(group) else ""),
1901
+ f"conversations: {round_conversation_label(group)}",
1902
+ f"source: {source}",
1903
+ *selection_lines(cmp.selection),
1904
+ *([f"evidence as of {as_of}"] if as_of else []),
1905
+ "",
1906
+ ]
1907
+ )
1908
+ # Stacked, not columnar: the TUI candidate pane is ~45 columns wide, so
1909
+ # each candidate is three short lines instead of one wrapped table row.
1910
+ for row in group.rows:
1911
+ head = (
1912
+ (">" if row.candidate_id == highlighted_id else " ")
1913
+ + f" {_clip(row.candidate_id[:8], 8)} {_clip(row.member_id, 12)} {_p(row.state)}"
1914
+ )
1915
+ if not row.readable:
1916
+ head += " UNREADABLE"
1917
+ if row.selected:
1918
+ head += " [selected]"
1919
+ time_text = "?" if row.duration_seconds is None else f"{row.duration_seconds:.1f}s"
1920
+ patch = _size(row.patch_bytes)
1921
+ if row.patch_binary:
1922
+ patch += " bin"
1923
+ if row.patch_large:
1924
+ patch += " large"
1925
+ files = "? files" if row.files_changed is None else f"{len(row.files_changed)} files"
1926
+ start = []
1927
+ if group.lineage.status == "recorded" and group.lineage.tree == "continued":
1928
+ parent = group.lineage.parents.get(row.member_id)
1929
+ start = [
1930
+ " from: parent not recorded"
1931
+ if parent is None
1932
+ else f" from: {_clip(parent[0][:8], 8)} (round {parent[1]})"
1933
+ ]
1934
+ entry = member_conversation(group, row)
1935
+ conv = []
1936
+ if entry is not None:
1937
+ conv = [
1938
+ f" conv: {entry.session}, "
1939
+ + (f"delta since r{entry.since_round}" if entry.context == "delta" else "full")
1940
+ + f", {len(entry.candidates)} cand"
1941
+ + (" +omitted" if entry.omitted else "")
1942
+ + f", mem {entry.memory}"
1943
+ ]
1944
+ lines.extend(
1945
+ [
1946
+ head,
1947
+ *start,
1948
+ *conv,
1949
+ f" gate {_GATE_TEXT.get(row.gate.verdict, row.gate.verdict)}"
1950
+ f" · {time_text} · {_usage_short(row.usage)}",
1951
+ f" {patch} · {files} · "
1952
+ + ("eligible" if row.eligible else "ineligible")
1953
+ + f" · blockers {len(row.apply_blockers)}",
1954
+ ]
1955
+ )
1956
+ if not group.rows:
1957
+ lines.append(" (no candidates retained in this round)")
1958
+ for member in group.missing:
1959
+ if member.status == "in_progress":
1960
+ lines.append(f" {_p(member.member_id)}: in progress")
1961
+ else:
1962
+ lines.append(
1963
+ f" {_p(member.member_id)}: no candidate retained ("
1964
+ + "; ".join(_p(e) for e in member.evidence)
1965
+ + ")"
1966
+ )
1967
+ overlap = group.overlap
1968
+ if group.rows:
1969
+ lines.append("")
1970
+ lines.append("files:")
1971
+ if len(overlap.compared) >= 2:
1972
+ lines.append(f" common to all ({len(overlap.common)}): {_capped(overlap.common)}")
1973
+ for candidate_id, names in overlap.only.items():
1974
+ lines.append(f" only {_p(candidate_id[:8])} ({len(names)}): {_capped(names)}")
1975
+ if overlap.partial:
1976
+ partial = tuple(overlap.partial)
1977
+ lines.append(f" changed by some, not all ({len(partial)}): {_capped(partial)}")
1978
+ if overlap.no_changes:
1979
+ lines.append(
1980
+ " no files changed: " + ", ".join(_p(c[:8]) for c in overlap.no_changes)
1981
+ )
1982
+ if overlap.unknown:
1983
+ lines.append(
1984
+ " file set unknown: " + ", ".join(_p(c[:8]) for c in overlap.unknown)
1985
+ )
1986
+ for warning in group.warnings:
1987
+ lines.append(f"warning: {_p(warning)}")
1988
+ for row in group.rows:
1989
+ for warning in row.warnings:
1990
+ lines.append(f"warning ({_p(row.candidate_id[:8])}): {_p(warning)}")
1991
+ lines.append("")
1992
+ lines.append("d/o/m on a row for its diff/output/details; r rebuilds this snapshot")
1993
+ return "\n".join(lines)
1994
+
1995
+
1996
+ # ------------------------------------------------------------ review export
1997
+
1998
+ #: Fixed last line of every review summary.
1999
+ REVIEW_FOOTER = (
2000
+ "Recorded evidence only. Selection is not apply; apply re-verifies the source and patch."
2001
+ )
2002
+
2003
+ _ASCII_PUNCTUATION = frozenset("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~")
2004
+
2005
+
2006
+ def _md(value: object) -> str:
2007
+ """An inline Markdown value: control characters escaped, then EVERY ASCII
2008
+ punctuation character backslash-escaped, so no heading, list, link,
2009
+ autolink, HTML or table cell boundary can form from it. Only ids, numbers
2010
+ and closed-vocabulary values are placed inline; free text goes through
2011
+ :func:`_md_fence`."""
2012
+ return "".join(
2013
+ "\\" + ch if ch in _ASCII_PUNCTUATION else ch for ch in _p(value)
2014
+ )
2015
+
2016
+
2017
+ def _md_fence(lines: list[str]) -> list[str]:
2018
+ """Free-form stored text as a fenced code block, literal in every
2019
+ CommonMark/GFM renderer: the fence is a backtick run longer than any run
2020
+ in the content (minimum 3), so no content line can close it."""
2021
+ body = [_p(line) for line in lines]
2022
+ longest = 0
2023
+ for line in body:
2024
+ run = 0
2025
+ for ch in line:
2026
+ run = run + 1 if ch == "`" else 0
2027
+ longest = max(longest, run)
2028
+ fence = "`" * max(3, longest + 1)
2029
+ return ["", fence, *body, fence, ""]
2030
+
2031
+
2032
+ def _md_table(header: list[str], rows: list[list[str]]) -> list[str]:
2033
+ """A GFM table; every cell must already be :func:`_md` output (or fixed
2034
+ template text)."""
2035
+ return [
2036
+ "",
2037
+ "| " + " | ".join(header) + " |",
2038
+ "|" + "|".join("---" for _ in header) + "|",
2039
+ *("| " + " | ".join(row) + " |" for row in rows),
2040
+ "",
2041
+ ]
2042
+
2043
+
2044
+ def _md_capped(names: tuple[str, ...]) -> list[str]:
2045
+ shown = [f" {name}" for name in names[:TABLE_FILE_CAP]]
2046
+ if len(names) > TABLE_FILE_CAP:
2047
+ shown.append(f" ... {len(names) - TABLE_FILE_CAP} more")
2048
+ return shown
2049
+
2050
+
2051
+ def _md_selection(selection: SelectionView | None) -> list[str]:
2052
+ lines = ["", "## Selection", ""]
2053
+ if selection is None:
2054
+ return [*lines, "Selected: none"]
2055
+ who = _md(selection.candidate_id) if selection.candidate_id else "none"
2056
+ if selection.status == "not_recorded":
2057
+ return [
2058
+ *lines,
2059
+ f"Selected (human): {who}",
2060
+ "",
2061
+ "Selection record: not recorded (selected before records were kept)",
2062
+ ]
2063
+ if selection.status != "bound":
2064
+ return [
2065
+ *lines,
2066
+ f"Selected (human): {who}",
2067
+ "",
2068
+ "Selection record: INCONSISTENT, reason:",
2069
+ *_md_fence([selection.reason or ""]),
2070
+ ]
2071
+ confirmed = (
2072
+ _md(selection.confirmed_revision)
2073
+ if selection.confirmed_revision is not None
2074
+ else "none (selected against the state at that moment)"
2075
+ )
2076
+ surface = _SURFACE_TEXT.get(selection.surface or "")
2077
+ lines.extend(
2078
+ _md_table(
2079
+ ["Field", "Value"],
2080
+ [
2081
+ ["Candidate (human choice)", who],
2082
+ ["Patch sha256", _md(selection.patch_sha256)],
2083
+ ["Confirmed revision", confirmed],
2084
+ ["Recorded as revision", _md(selection.revision)],
2085
+ ["Surface", surface or _md(selection.surface)],
2086
+ ["Selected at", _md(selection.selected_at)],
2087
+ ],
2088
+ )
2089
+ )
2090
+ if selection.rationale is None:
2091
+ lines.append("Rationale: none recorded")
2092
+ else:
2093
+ lines.append("Rationale:")
2094
+ lines.extend(_md_fence(selection.rationale.split("\n")))
2095
+ return lines
2096
+
2097
+
2098
+ def _md_patch(row: CandidateRow) -> str:
2099
+ if row.patch_bytes is None:
2100
+ return "unknown"
2101
+ patch = f"{row.patch_bytes:,} bytes"
2102
+ if row.patch_binary:
2103
+ patch += ", binary"
2104
+ if row.patch_large:
2105
+ patch += ", large"
2106
+ return _md(patch)
2107
+
2108
+
2109
+ def _md_candidate(
2110
+ row: CandidateRow,
2111
+ start: str | None = None,
2112
+ conversation: str | None = None,
2113
+ entry: MemberConversation | None = None,
2114
+ ) -> list[str]:
2115
+ attempt = "unknown" if row.attempt is None else _md(row.attempt)
2116
+ lines = ["", f"### Candidate {_md(row.candidate_id)}"]
2117
+ lines.extend(
2118
+ _md_table(
2119
+ ["Field", "Value"],
2120
+ [
2121
+ ["Member", _md(row.member_id)],
2122
+ ["Provider", _md(row.provider_id)],
2123
+ ["Attempt", attempt],
2124
+ ["State", _md(row.state)],
2125
+ ["Selected", "yes (human)" if row.selected else "no"],
2126
+ ["Readable", "yes" if row.readable else "no"],
2127
+ ["Patch sha256", _md(row.patch_sha256)],
2128
+ ["Base commit", _md(row.base_commit)],
2129
+ *([["Starts from", _md(start)]] if start is not None else []),
2130
+ *([["Conversation", _md(conversation)]] if conversation is not None else []),
2131
+ *(
2132
+ [["Checkpoint candidates", _md(", ".join(entry.candidates)) or "none"]]
2133
+ if entry is not None
2134
+ else []
2135
+ ),
2136
+ ],
2137
+ )
2138
+ )
2139
+ if not row.readable:
2140
+ lines.append("Unreadable, reason:")
2141
+ lines.extend(_md_fence([row.unreadable_reason or ""]))
2142
+ lines.append(f"Gate: {_md(_GATE_TEXT.get(row.gate.verdict, row.gate.verdict))}")
2143
+ if row.gate.reason:
2144
+ lines.extend(["", "Gate reason:", *_md_fence([row.gate.reason])])
2145
+ if row.gate.checks:
2146
+ lines.extend(
2147
+ _md_table(
2148
+ ["Check", "Result", "Exit code", "Duration"],
2149
+ [
2150
+ [
2151
+ _md(index),
2152
+ "pass" if check.passed else "fail",
2153
+ "not recorded" if check.exit_code is None else _md(check.exit_code),
2154
+ "not recorded"
2155
+ if check.duration_seconds is None
2156
+ else _md(f"{check.duration_seconds:.1f}s"),
2157
+ ]
2158
+ for index, check in enumerate(row.gate.checks, 1)
2159
+ ],
2160
+ )
2161
+ )
2162
+ lines.append("Check commands:")
2163
+ lines.extend(
2164
+ _md_fence([f"{i}: {check.command}" for i, check in enumerate(row.gate.checks, 1)])
2165
+ )
2166
+ if row.eligible:
2167
+ lines.extend(["", "Eligible: yes"])
2168
+ elif row.not_eligible_reason:
2169
+ lines.extend(["", "Eligible: no, reason:", *_md_fence([row.not_eligible_reason])])
2170
+ else:
2171
+ lines.extend(["", "Eligible: no"])
2172
+ if row.apply_blockers:
2173
+ lines.extend(["", "Apply blockers:", *_md_fence(list(row.apply_blockers))])
2174
+ else:
2175
+ lines.extend(["", "Apply blockers: none known (select/apply still revalidate)"])
2176
+ if row.warnings:
2177
+ lines.extend(["", "Warnings:", *_md_fence(list(row.warnings))])
2178
+ return lines
2179
+
2180
+
2181
+ def _md_overlap(overlap: FileOverlap) -> list[str]:
2182
+ body: list[str] = []
2183
+ if len(overlap.compared) >= 2:
2184
+ body.append(f"common to all ({len(overlap.common)}):")
2185
+ body.extend(_md_capped(overlap.common))
2186
+ for candidate_id, names in overlap.only.items():
2187
+ body.append(f"only {candidate_id} ({len(names)}):")
2188
+ body.extend(_md_capped(names))
2189
+ if overlap.partial:
2190
+ partial = tuple(overlap.partial)
2191
+ body.append(f"changed by some, not all ({len(partial)}):")
2192
+ for name in partial[:TABLE_FILE_CAP]:
2193
+ body.append(f" {name}: {', '.join(overlap.partial[name])}")
2194
+ if len(partial) > TABLE_FILE_CAP:
2195
+ body.append(f" ... {len(partial) - TABLE_FILE_CAP} more")
2196
+ if overlap.no_changes:
2197
+ body.append("no files changed: " + ", ".join(overlap.no_changes))
2198
+ if overlap.unknown:
2199
+ body.append("file set unknown: " + ", ".join(overlap.unknown))
2200
+ if not body:
2201
+ return []
2202
+ return ["", "Files:", *_md_fence(body)]
2203
+
2204
+
2205
+ def _md_round(group: RoundGroup, round_count: int) -> list[str]:
2206
+ lines = [
2207
+ "",
2208
+ f"## Round {_md(group.number)} of {_md(round_count)} ({_md(group.state)})",
2209
+ "",
2210
+ f"Round id: {_md(group.round_id)}",
2211
+ "",
2212
+ f"Starting tree: {_md(starting_tree_label(group))}",
2213
+ "",
2214
+ ]
2215
+ lines.extend([f"Conversations: {_md(round_conversation_label(group))}", ""])
2216
+ settings = group.gate_settings
2217
+ if settings.status == "recorded" and settings.kind == "commands":
2218
+ lines.append(f"Gate settings: {_md(gate_settings_label(settings))}:")
2219
+ lines.extend(_md_fence(list(settings.commands)))
2220
+ else:
2221
+ lines.extend([f"Gate settings: {_md(gate_settings_label(settings))}", ""])
2222
+ profile = group.gate_profile
2223
+ if profile.kind == "commands":
2224
+ lines.append(
2225
+ "Gate reports: commands (timeout not recorded):"
2226
+ if _timeout_note(group)
2227
+ else "Gate reports: commands:"
2228
+ )
2229
+ lines.extend(_md_fence(list(profile.commands)))
2230
+ else:
2231
+ lines.append(
2232
+ "Gate reports: "
2233
+ + {
2234
+ "no_report": "no gate report on any completed candidate",
2235
+ "unknown": "unknown (no completed candidate with a usable record)",
2236
+ "mixed": "mixed (records disagree, see warnings)",
2237
+ }.get(profile.kind, _md(profile.kind))
2238
+ )
2239
+ if not group.rows and not group.missing:
2240
+ lines.extend(["", "No candidates."])
2241
+ if group.rows:
2242
+ lines.extend(
2243
+ _md_table(
2244
+ [
2245
+ "Candidate", "Provider", "State", "Gate", "Eligible",
2246
+ "Blockers", "Duration", "Usage", "Files", "Patch",
2247
+ ],
2248
+ [
2249
+ [
2250
+ _md(row.candidate_id)
2251
+ + (" (selected, human)" if row.selected else ""),
2252
+ _md(row.provider_id),
2253
+ _md(row.state),
2254
+ _md(_GATE_TEXT.get(row.gate.verdict, row.gate.verdict)),
2255
+ "yes" if row.eligible else "no",
2256
+ _md(len(row.apply_blockers)),
2257
+ "not recorded"
2258
+ if row.duration_seconds is None
2259
+ else _md(f"{row.duration_seconds:.1f}s"),
2260
+ _md(_usage_text(row.usage)),
2261
+ "unknown"
2262
+ if row.files_changed is None
2263
+ else _md(len(row.files_changed)),
2264
+ _md_patch(row),
2265
+ ]
2266
+ for row in group.rows
2267
+ ],
2268
+ )
2269
+ )
2270
+ if group.missing:
2271
+ lines.extend(["", "Members without a retained candidate:"])
2272
+ lines.extend(
2273
+ _md_table(
2274
+ ["Member", "Provider", "Status"],
2275
+ [
2276
+ [
2277
+ _md(m.member_id),
2278
+ _md(m.provider_id),
2279
+ "in progress" if m.status == "in_progress" else "no candidate retained",
2280
+ ]
2281
+ for m in group.missing
2282
+ ],
2283
+ )
2284
+ )
2285
+ evidence = [
2286
+ f"{m.member_id}: " + "; ".join(m.evidence)
2287
+ for m in group.missing
2288
+ if m.status != "in_progress"
2289
+ ]
2290
+ if evidence:
2291
+ lines.append("Progress evidence:")
2292
+ lines.extend(_md_fence(evidence))
2293
+ if group.rows:
2294
+ lines.extend(_md_overlap(group.overlap))
2295
+ if group.warnings:
2296
+ lines.extend(["", "Round warnings:", *_md_fence(list(group.warnings))])
2297
+ for row in group.rows:
2298
+ lines.extend(
2299
+ _md_candidate(
2300
+ row,
2301
+ row_start_label(group, row),
2302
+ conversation_label(group, row),
2303
+ member_conversation(group, row),
2304
+ )
2305
+ )
2306
+ return lines
2307
+
2308
+
2309
+ def render_review_markdown(
2310
+ cmp: RoomComparison, *, generated_at: str, appendix: list[str] | None = None
2311
+ ) -> str:
2312
+ """A portable, body-free review summary of one comparison.
2313
+
2314
+ It carries the same qualifications as :func:`render_room_comparison` (the
2315
+ starting tree, ``timeout not recorded``, every gate check), so a handoff
2316
+ never implies equal starting trees or gate settings. Escaping is by
2317
+ position: inline cells and header values hold only :func:`_md` output,
2318
+ and every free-form stored string (rationale, file names, gate commands,
2319
+ warnings, reasons) sits in a :func:`_md_fence` block.
2320
+
2321
+ ``appendix`` lines go before the footer; this module never builds them
2322
+ (the explicit raw-content section comes from ``room_raw``)."""
2323
+ source = _SOURCE_TEXT.get(cmp.source_status)
2324
+ lines = [
2325
+ f"# Room review: {_md(cmp.room_id)}",
2326
+ *_md_table(
2327
+ ["Field", "Value"],
2328
+ [
2329
+ ["Room", _md(cmp.room_id)],
2330
+ ["State", _md(cmp.room_state)],
2331
+ ["Revision", _md(cmp.revision)],
2332
+ ["Source", _md(source) if source else "check unavailable"],
2333
+ ["Generated", _md(generated_at)],
2334
+ ],
2335
+ ),
2336
+ ]
2337
+ if source is None:
2338
+ lines.extend(["Source check unavailable, reason:", *_md_fence([cmp.source_reason or ""])])
2339
+ lines.extend(_md_selection(cmp.selection))
2340
+ if cmp.damaged_rounds:
2341
+ lines.extend(["", "## Damaged rounds"])
2342
+ for entry in cmp.damaged_rounds:
2343
+ lines.extend(["", f"Round {_md(entry['round_id'])}:", *_md_fence([entry["reason"]])])
2344
+ if not cmp.groups:
2345
+ lines.extend(
2346
+ [
2347
+ "",
2348
+ "No readable rounds in this room."
2349
+ if cmp.damaged_rounds
2350
+ else "No rounds have run in this room yet.",
2351
+ ]
2352
+ )
2353
+ for group in cmp.groups:
2354
+ lines.extend(_md_round(group, cmp.round_count))
2355
+ if cmp.cross_round or cmp.cross_round_settings:
2356
+ lines.extend(["", "## Across rounds"])
2357
+ # Commands and reasons are free text, so the whole summary is fenced.
2358
+ lines.extend(_md_fence(cross_round_lines(cmp, str)))
2359
+ if appendix:
2360
+ lines.extend(appendix)
2361
+ lines.extend(["", "---", "", REVIEW_FOOTER])
2362
+ return "\n".join(_squeeze(lines)) + "\n"
2363
+
2364
+
2365
+ def _squeeze(lines: list[str]) -> list[str]:
2366
+ """Collapse runs of blank lines between blocks (block helpers pad both
2367
+ sides), never inside a fence: a blank line there is stored content."""
2368
+ out: list[str] = []
2369
+ fence: str | None = None
2370
+ for line in lines:
2371
+ if fence is None and len(line) >= 3 and set(line) == {"`"}:
2372
+ fence = line
2373
+ elif fence is not None and line == fence:
2374
+ fence = None
2375
+ elif fence is None and line == "" and (not out or out[-1] == ""):
2376
+ continue
2377
+ out.append(line)
2378
+ while out and out[-1] == "":
2379
+ out.pop()
2380
+ return out