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
syncmill/room_raw.py ADDED
@@ -0,0 +1,484 @@
1
+ """Explicit raw content for ``room export`` (#189).
2
+
3
+ ``room export`` is body-free by default. This module adds stored prompts, agent
4
+ output, gate check output and patches only for targets the user names, and
5
+ only in two steps: ``--preview`` shows what would be included and prints a
6
+ token; ``--confirm TOKEN`` collects everything again and writes it only when
7
+ the recomputed token is the same. The token is a digest of every item's
8
+ descriptor (kind, target, observed room and round, the full artifact digest
9
+ and size, how much is included, the integrity class and the source note), so
10
+ a confirmation is bound to exactly what was previewed.
11
+
12
+ Integrity differs by kind and every item says which applies:
13
+
14
+ - ``sqlite-digest``: patch and output, streamed once and checked against the
15
+ digest the SQLite candidate row holds.
16
+ - ``manifest-record``: gate check output, stored in the candidate manifest,
17
+ which has no independent digest; it is read through the comparison's own
18
+ path (:func:`room_compare.read_candidate_gate`) and accepted only when that
19
+ path accepts the record.
20
+ - ``room-database``: a round's prompt, read from its SQLite row.
21
+
22
+ Raw bytes are never redacted (they would no longer match their digests); a
23
+ warning says they may contain secrets. ``room_compare`` stays body-free: it
24
+ only places the lines this module renders before its footer. A flat top-level
25
+ module imported only by the CLI.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import base64
31
+ import json
32
+ import re
33
+ from collections.abc import Iterable
34
+ from dataclasses import asdict, dataclass, field
35
+ from hashlib import sha256
36
+ from typing import Any
37
+
38
+ from syncmill.artifacts import printable_name, printable_text
39
+ from syncmill.room_compare import (
40
+ _GATE_TEXT,
41
+ RoomComparison,
42
+ RoundGroup,
43
+ _md,
44
+ _md_table,
45
+ read_candidate_gate,
46
+ )
47
+ from syncmill.rooms import RoomError, RoomStore
48
+
49
+ KINDS = ("patch", "output", "gate-output", "prompt")
50
+
51
+ #: Bytes included per item; digests always cover the whole artifact.
52
+ DEFAULT_MAX_BYTES = 256 * 1024
53
+
54
+ #: Lines of each item the preview shows.
55
+ PREVIEW_LINES = 8
56
+
57
+ TOKEN_VERSION = 1
58
+
59
+ WARNING = (
60
+ "Raw content is included verbatim: prompts, agent output, gate output and "
61
+ "patches. It may contain secrets such as tokens, credentials, file paths "
62
+ "or environment values. Review it before sharing."
63
+ )
64
+
65
+ DISPLAY_NOTE = (
66
+ "Each block is a display rendering: control characters and invalid UTF-8 "
67
+ "are shown as escapes such as \\x1b, which look the same as literal text "
68
+ "such as \\x41. The sha256 is of the stored bytes; use --format json for "
69
+ "byte-exact content."
70
+ )
71
+
72
+ _SOURCE_NOTES = {
73
+ "patch": "stored candidate patch",
74
+ "output": "stored provider output",
75
+ "gate-output": (
76
+ "stored gate check output text (the shipped CommandGate keeps only the "
77
+ "last 4000 characters of a check's output); the sha256 identifies this "
78
+ "stored text, not the original process output"
79
+ ),
80
+ "prompt": (
81
+ "the round's user-authored prompt as stored in the room database; each "
82
+ "provider also received checkpoint and memory context that is not part "
83
+ "of this text and may differ per member"
84
+ ),
85
+ }
86
+
87
+ _INTEGRITY_TEXT = {
88
+ "sqlite-digest": "sha256 matches the SQLite candidate row",
89
+ "manifest-record": "manifest record; no independent digest",
90
+ "room-database": "room database row",
91
+ }
92
+
93
+
94
+ @dataclass(frozen=True, order=True)
95
+ class RawSpec:
96
+ """One ``--include KIND:TARGET``, canonicalized."""
97
+
98
+ kind: str
99
+ target: str
100
+
101
+
102
+ def parse_include(value: str) -> RawSpec:
103
+ """Parse ``KIND:TARGET``. A candidate id must be given in full (an export
104
+ binds content, so no prefix matching); a round number is canonicalized so
105
+ ``prompt:01`` and ``prompt:1`` are the same include."""
106
+ kind, sep, target = value.partition(":")
107
+ if not sep or kind not in KINDS:
108
+ raise ValueError(f"expected KIND:TARGET with KIND one of {', '.join(KINDS)}")
109
+ if kind == "prompt":
110
+ if not re.fullmatch(r"[0-9]+", target) or int(target) < 1:
111
+ raise ValueError("prompt takes a round number (1 or more)")
112
+ return RawSpec(kind, str(int(target)))
113
+ if not target:
114
+ raise ValueError(f"{kind} takes a full candidate id")
115
+ return RawSpec(kind, target)
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class RawItem:
120
+ """One included block. Every field but ``head`` is its descriptor, and the
121
+ whole descriptor is bound into the token."""
122
+
123
+ kind: str
124
+ target: str
125
+ #: 1-based gate check index (gate-output only).
126
+ check: int | None
127
+ #: The check's stored command (gate-output only).
128
+ command: str | None
129
+ room_id: str
130
+ round_id: str
131
+ sha256: str
132
+ size: int
133
+ included_bytes: int
134
+ truncated: bool
135
+ integrity: str
136
+ source_note: str
137
+ head: bytes = field(repr=False)
138
+
139
+ def descriptor(self) -> dict[str, Any]:
140
+ data = asdict(self)
141
+ del data["head"]
142
+ return data
143
+
144
+
145
+ def _item(
146
+ spec: RawSpec,
147
+ *,
148
+ room_id: str,
149
+ round_id: str,
150
+ data: bytes,
151
+ size: int,
152
+ digest: str,
153
+ integrity: str,
154
+ source_note: str,
155
+ max_bytes: int,
156
+ check: int | None = None,
157
+ command: str | None = None,
158
+ ) -> RawItem:
159
+ head = data[:max_bytes]
160
+ return RawItem(
161
+ kind=spec.kind,
162
+ target=spec.target,
163
+ check=check,
164
+ command=command,
165
+ room_id=room_id,
166
+ round_id=round_id,
167
+ sha256=digest,
168
+ size=size,
169
+ included_bytes=len(head),
170
+ truncated=size > len(head),
171
+ integrity=integrity,
172
+ source_note=source_note,
173
+ head=head,
174
+ )
175
+
176
+
177
+ def collect_raw(
178
+ store: RoomStore,
179
+ comparison: RoomComparison,
180
+ specs: Iterable[RawSpec],
181
+ max_bytes: int,
182
+ ) -> list[RawItem]:
183
+ """Read every requested item from the store, verified, in canonical order.
184
+
185
+ Each target must lie inside the comparison's rounds; the reads are bound
186
+ to the room, round and digests the comparison resolved, so a row that
187
+ changed since is refused rather than exported under the old scope."""
188
+ candidates = {
189
+ row.candidate_id: (group, row) for group in comparison.groups for row in group.rows
190
+ }
191
+ rounds = {group.number: group for group in comparison.groups}
192
+ items: list[RawItem] = []
193
+ for spec in sorted(set(specs)):
194
+ if spec.kind == "prompt":
195
+ group = rounds.get(int(spec.target))
196
+ if group is None:
197
+ raise RoomError(
198
+ f"round {spec.target} is not in this export "
199
+ "(choose it with --round or use --all-rounds)"
200
+ )
201
+ items.append(_prompt_item(store, comparison.room_id, group, spec, max_bytes))
202
+ continue
203
+ found = candidates.get(spec.target)
204
+ if found is None:
205
+ raise RoomError(f"candidate {spec.target} is not in this export's rounds")
206
+ group, row = found
207
+ expected = {
208
+ "room_id": comparison.room_id,
209
+ "round_id": group.round_id,
210
+ "patch_sha256": row.patch_sha256,
211
+ "output_sha256": row.output_sha256,
212
+ }
213
+ if spec.kind == "gate-output":
214
+ items.extend(_gate_items(store, spec, expected, max_bytes))
215
+ continue
216
+ read = store.load_candidate_raw_head(
217
+ spec.target, spec.kind, max_bytes, expected=expected
218
+ )
219
+ note = _SOURCE_NOTES[spec.kind]
220
+ if spec.kind == "output" and read["manifest"].get("output_truncated") is True:
221
+ note += ", truncated at save"
222
+ items.append(
223
+ _item(
224
+ spec,
225
+ room_id=str(read["row"]["room_id"]),
226
+ round_id=str(read["row"]["round_id"]),
227
+ data=read["head"],
228
+ size=read["size"],
229
+ digest=read["sha256"],
230
+ integrity="sqlite-digest",
231
+ source_note=note,
232
+ max_bytes=max_bytes,
233
+ )
234
+ )
235
+ return items
236
+
237
+
238
+ def _prompt_item(
239
+ store: RoomStore, room_id: str, group: RoundGroup, spec: RawSpec, max_bytes: int
240
+ ) -> RawItem:
241
+ row = store.get_round(group.round_id)
242
+ if (
243
+ row.get("room_id") != room_id
244
+ or row.get("number") != group.number
245
+ or row.get("id") != group.round_id
246
+ ):
247
+ raise RoomError(f"round {group.number} changed since the export resolved it")
248
+ prompt = row.get("prompt")
249
+ try:
250
+ if not isinstance(prompt, str):
251
+ raise TypeError("prompt is not text")
252
+ data = prompt.encode("utf-8")
253
+ except (TypeError, UnicodeEncodeError) as exc:
254
+ raise RoomError(f"round {group.number} prompt is not valid Unicode text") from exc
255
+ return _item(
256
+ spec,
257
+ room_id=room_id,
258
+ round_id=group.round_id,
259
+ data=data,
260
+ size=len(data),
261
+ digest=sha256(data).hexdigest(),
262
+ integrity="room-database",
263
+ source_note=_SOURCE_NOTES["prompt"],
264
+ max_bytes=max_bytes,
265
+ )
266
+
267
+
268
+ def _gate_items(
269
+ store: RoomStore, spec: RawSpec, expected: dict[str, Any], max_bytes: int
270
+ ) -> list[RawItem]:
271
+ row = store.get_candidate(spec.target)
272
+ store._check_expected_row(spec.target, row, expected)
273
+ # The comparison's own path (artifact verification, then the gate policy),
274
+ # not a copy of it: raw export never shows checks the comparison discards.
275
+ read = read_candidate_gate(
276
+ store, spec.target, state=str(row["state"]), eligible=bool(row["eligible"])
277
+ )
278
+ verdict, manifest = read.gate, read.manifest
279
+ if verdict.verdict not in ("passed", "failed") or manifest is None:
280
+ reason = read.unreadable or verdict.reason or _GATE_TEXT.get(verdict.verdict, verdict.verdict)
281
+ raise RoomError(f"candidate {spec.target} has no usable gate record: {reason}")
282
+ # The manifest was validated against the row as it was at read time; it
283
+ # must still be the row the comparison resolved this target from.
284
+ for key in ("room_id", "round_id", "patch_sha256", "output_sha256"):
285
+ if manifest.get(key) != expected[key]:
286
+ raise RoomError(
287
+ f"candidate {key} changed since the export resolved it: {spec.target}"
288
+ )
289
+ if not manifest["gate"]["checks"]:
290
+ # An include must add at least one item: zero items would leave the
291
+ # token unchanged by the include and export nothing for it.
292
+ raise RoomError(f"candidate {spec.target} has a gate report with no checks")
293
+ items: list[RawItem] = []
294
+ for index, check in enumerate(manifest["gate"]["checks"], 1):
295
+ try:
296
+ command = check["command"]
297
+ command.encode("utf-8")
298
+ data = check["output"].encode("utf-8")
299
+ except UnicodeEncodeError as exc:
300
+ raise RoomError(
301
+ f"candidate {spec.target} gate check {index} is not valid Unicode text"
302
+ ) from exc
303
+ items.append(
304
+ _item(
305
+ spec,
306
+ room_id=expected["room_id"],
307
+ round_id=expected["round_id"],
308
+ data=data,
309
+ size=len(data),
310
+ digest=sha256(data).hexdigest(),
311
+ integrity="manifest-record",
312
+ source_note=_SOURCE_NOTES["gate-output"],
313
+ max_bytes=max_bytes,
314
+ check=index,
315
+ command=command,
316
+ )
317
+ )
318
+ return items
319
+
320
+
321
+ def scope_of(comparison: RoomComparison, *, all_rounds: bool) -> dict[str, Any]:
322
+ """The resolved export scope bound into the token: the default (latest)
323
+ round is named by its number, so a new round changes it."""
324
+ if all_rounds:
325
+ return {"all_rounds": True}
326
+ return {"round": comparison.groups[0].number if comparison.groups else None}
327
+
328
+
329
+ def preview_token(
330
+ room_id: str, scope: dict[str, Any], max_bytes: int, items: list[RawItem]
331
+ ) -> str:
332
+ document = {
333
+ "v": TOKEN_VERSION,
334
+ "room": room_id,
335
+ "scope": scope,
336
+ "cap": max_bytes,
337
+ "items": [item.descriptor() for item in items],
338
+ }
339
+ canonical = json.dumps(
340
+ document, sort_keys=True, separators=(",", ":"), ensure_ascii=True
341
+ )
342
+ return sha256(canonical.encode("ascii")).hexdigest()
343
+
344
+
345
+ # ------------------------------------------------------------- rendering
346
+
347
+
348
+ def _render_line(line: str) -> str:
349
+ # One display line: a gate command is a one-line value, so an LF in it is
350
+ # escaped the way printable_name writes it rather than starting a new line.
351
+ return printable_text(line).replace("\n", "\\x0a")
352
+
353
+
354
+ def display_lines(data: bytes) -> tuple[list[str], bool]:
355
+ """``(lines, ends_with_newline)`` of the display rendering. LF splits lines
356
+ and one final LF is a terminator; TAB stays literal; every other
357
+ non-printable character and every invalid UTF-8 byte becomes an escape."""
358
+ parts = data.decode("utf-8", errors="surrogateescape").split("\n")
359
+ ends_with_newline = parts[-1] == ""
360
+ if ends_with_newline:
361
+ parts.pop()
362
+ return [_render_line(part) for part in parts], ends_with_newline
363
+
364
+
365
+ def _p(value: object) -> str:
366
+ return printable_name(str(value))
367
+
368
+
369
+ def render_preview(items: list[RawItem], token: str, room_id: str) -> str:
370
+ lines = [f"Raw content preview for room {_p(room_id)}", ""]
371
+ for number, item in enumerate(items, 1):
372
+ title = f"[{number}] {item.kind} {_p(item.target)}"
373
+ if item.check is not None:
374
+ title += f" check {item.check}"
375
+ lines.append(title)
376
+ if item.command is not None:
377
+ lines.append(f" command: {_p(item.command)}")
378
+ lines.append(f" integrity: {_INTEGRITY_TEXT[item.integrity]}")
379
+ lines.append(f" size: {item.size:,} bytes, sha256 {item.sha256}")
380
+ lines.append(
381
+ f" included: {item.included_bytes:,} bytes"
382
+ + (" (truncated)" if item.truncated else "")
383
+ )
384
+ lines.append(f" source: {item.source_note}")
385
+ body, _ = display_lines(item.head)
386
+ shown = body[:PREVIEW_LINES]
387
+ lines.append(f" first {len(shown)} line(s):")
388
+ lines.extend(f" | {line}" for line in shown)
389
+ if len(body) > len(shown) or item.truncated:
390
+ lines.append(" | ...")
391
+ lines.append("")
392
+ total = sum(item.included_bytes for item in items)
393
+ lines.extend(
394
+ [
395
+ f"{len(items)} item(s), {total:,} bytes to include.",
396
+ "",
397
+ f"WARNING: {WARNING}",
398
+ "",
399
+ f"Token: {token}",
400
+ f"To write: rerun the same command with --preview replaced by --confirm {token}",
401
+ "(add --output FILE to write a file).",
402
+ ]
403
+ )
404
+ return "\n".join(lines) + "\n"
405
+
406
+
407
+ def _md_raw_fence(lines: list[str]) -> list[str]:
408
+ """Already-rendered lines as one fenced block whose fence outlasts every
409
+ backtick run in them (the #186 rule). Fence and body are separate list
410
+ entries so the review renderer's blank-line squeeze sees the fence."""
411
+ longest = 0
412
+ for line in lines:
413
+ run = 0
414
+ for ch in line:
415
+ run = run + 1 if ch == "`" else 0
416
+ longest = max(longest, run)
417
+ fence = "`" * max(3, longest + 1)
418
+ return ["", fence, *lines, fence, ""]
419
+
420
+
421
+ def render_markdown_section(items: list[RawItem], token: str) -> list[str]:
422
+ lines = [
423
+ "",
424
+ "## Raw content (explicitly included)",
425
+ "",
426
+ f"WARNING: {_md(WARNING)}",
427
+ "",
428
+ _md(DISPLAY_NOTE),
429
+ "",
430
+ _md("The comparison above was built when this export was confirmed."),
431
+ "",
432
+ f"Confirmed with token: {_md(token)}",
433
+ ]
434
+ for number, item in enumerate(items, 1):
435
+ title = f"Raw {number}: {item.kind} {item.target}"
436
+ if item.check is not None:
437
+ title += f" check {item.check}"
438
+ lines.extend(["", f"### {_md(title)}"])
439
+ lines.extend(
440
+ _md_table(
441
+ ["Field", "Value"],
442
+ [
443
+ ["Kind", _md(item.kind)],
444
+ ["Target", _md(item.target)],
445
+ *([["Check", _md(item.check)]] if item.check is not None else []),
446
+ ["Round id", _md(item.round_id)],
447
+ ["Integrity", _md(_INTEGRITY_TEXT[item.integrity])],
448
+ ["Size", _md(f"{item.size:,} bytes")],
449
+ ["Sha256", _md(item.sha256)],
450
+ ["Included", _md(f"{item.included_bytes:,} bytes")],
451
+ ["Truncated", "yes" if item.truncated else "no"],
452
+ ],
453
+ )
454
+ )
455
+ if item.command is not None:
456
+ lines.append("Command:")
457
+ lines.extend(_md_raw_fence([_render_line(item.command)]))
458
+ lines.append("Source:")
459
+ lines.extend(_md_raw_fence([item.source_note]))
460
+ body, ends_with_newline = display_lines(item.head)
461
+ lines.append("Content:")
462
+ lines.extend(_md_raw_fence(body))
463
+ if item.truncated:
464
+ lines.append(
465
+ _md(f"[truncated: {item.included_bytes:,} of {item.size:,} bytes shown]")
466
+ )
467
+ elif body and not ends_with_newline:
468
+ lines.append(_md("[no newline at end of content]"))
469
+ return lines
470
+
471
+
472
+ def json_section(items: list[RawItem], token: str) -> dict[str, Any]:
473
+ return {
474
+ "token": token,
475
+ "warning": WARNING,
476
+ "items": [
477
+ {
478
+ **item.descriptor(),
479
+ "encoding": "base64",
480
+ "data": base64.b64encode(item.head).decode("ascii"),
481
+ }
482
+ for item in items
483
+ ],
484
+ }