Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,16 @@ All notable changes to SyncMill are documented here.
checkpoint and before the task, with the excerpts and memory context shrinking
to make room. A resumed conversation keeps what earlier rounds sent it, so a
round that would resume one in a room with earlier lessons adds a note about
`--fresh-session`. TUI-launched rounds do not send lessons yet.
`--fresh-session`.
- The TUI Rooms tab records verdicts and promotes lessons (#174): `j` records
accepted/rejected on an applied room, bound to the patch digest the dialog
showed; `p` promotes a lesson from a judged room and refuses if the verdict
changed while the dialog was open (`RoomCoordinator.promote_lesson` gains
`expected_judgment_id`). The room pane shows the outcome. TUI rounds now send
the project's approved lessons: the round dialog lists them with checkboxes,
applies the CLI's skip and budget rules live, and refuses to start when the
prompt leaves no room for the checked lessons; the history note shows there,
before you confirm, for the lineage you pick.
The round conversation record is now `schema_version` 2: it adds the
selected lesson ids and each member's delivery, shown by `room compare`,
`room export` and the TUI (`rounds[].conversation.lessons`,
Expand Down Expand Up @@ -70,6 +79,16 @@ All notable changes to SyncMill are documented here.

### Fixed

- The TUI no longer exits when the board's decisions or the selected item
cannot be read during a Board-tab refresh; it shows `DEGRADED` and keeps
the last view, as it already did for the item list.
- A TUI round, verdict or promotion acts on the room its dialog was opened
for. A round dialog used to launch on whichever room was selected when you
confirmed, which a refresh could have changed meanwhile.
- A verdict recorded from any surface now marks an open TUI Compare or
Details snapshot as changed; a verdict changes neither the room's revision
nor its candidates, so the snapshot used to keep showing the old outcome
without saying so.
- `syncmill start` suggests verification commands only from files whose
name matches exactly, so each suggestion names a file that is on disk. On a
case-insensitive filesystem (the macOS default) a repository with only a
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ uv run syncmill room export <room-id> --include patch:<cand> [--include output:|
# room rounds record their effective starting tree + gate settings (v2 adds the gate's env_allowlist: null = inherited, [] distinct) in rounds.provenance_json (rooms.round_provenance_status, writer and readers share it, readers accept v1+v2; NULL = legacy); compare/export/TUI show them as starting tree / gate settings beside the report-derived gate profile, whose JSON fields are unchanged; a v1 round's environment reads not recorded and never groups as same settings; `room apply --verify` still runs the current `[gate]` and prints a `verify:` notice (`rooms.verify_settings_notice`) unless the round's v2 record equals it
uv run syncmill room select <room-id> <cand> [--expected-revision N --expected-digest SHA256 --rationale TEXT] # verifies the artifact, records a bound selection (rooms.selection_json); apply refuses an inconsistent record
uv run syncmill room judge <room-id> accepted|rejected [--expected-digest SHA256 --rationale TEXT] # #174: append-only human verdict on the APPLIED candidate (room_judgments, bound to candidate + patch digest + applied run; never inferred from select/apply, never touches memory, no state/revision change); rooms.room_outcome_status is the one read model (not_selected / selected_not_applied / applied_unreviewed / accepted / rejected / unknown — a damaged latest record is unknown, never an older verdict) shown by compare/export as room.outcome
uv run syncmill room promote <room-id> --lesson TEXT [--scope GOAL --supersedes ID] # #174 PR 2: needs the board; only a judged room (accepted/rejected); a board decision bound to project_id with provenance_json (room/judgment/verdict/candidate/digest/run) and a unique source_key (retry returns the existing decision, never reactivates a superseded one); `run` shows then injects Board.project_lessons (project-wide scope only; board.select_lessons budget; --skip-lesson/--no-lessons, forwarded in _inner_argv) via Supervisor.run(lessons=) AFTER routing classified the raw prompt; board items get unbound + own-project decisions; room rounds: `room round`/`start` show them (cli._show_round_lessons, rooms.round_lessons_fit) and pass RoundLesson pairs to run_round(lessons=) (detached: a second anonymous fd, --lessons-fd); the coordinator never opens the board; lessons sit after the checkpoint, before the task, excerpts budgeted around them; TUI rounds send none (note only)
uv run syncmill room promote <room-id> --lesson TEXT [--scope GOAL --supersedes ID] # #174 PR 2: needs the board; only a judged room (accepted/rejected); a board decision bound to project_id with provenance_json (room/judgment/verdict/candidate/digest/run) and a unique source_key (retry returns the existing decision, never reactivates a superseded one); `run` shows then injects Board.project_lessons (project-wide scope only; board.select_lessons budget; --skip-lesson/--no-lessons, forwarded in _inner_argv) via Supervisor.run(lessons=) AFTER routing classified the raw prompt; board items get unbound + own-project decisions; room rounds: `room round`/`start` show them (cli._show_round_lessons, rooms.round_lessons_fit) and pass RoundLesson pairs to run_round(lessons=) (detached: a second anonymous fd, --lessons-fd); the coordinator never opens the board; lessons sit after the checkpoint, before the task, excerpts budgeted around them; TUI (Rooms `j` verdict / `p` promote, `tui.py` `_confirm_room_judge`/`_confirm_lesson_promote`): the round dialog reads `project_lessons` once on the Textual thread (`RoundLessonsView`), recomputes `select_lessons(skip=unchecked)` live, refuses (never drops) when `round_lessons_fit` fails on `round_prompt`'s fallback rule, and launches with exactly those pairs; promote passes `expected_judgment_id` (checked on the same outcome read as the provenance); the round/verdict/promote dialog callbacks act on the captured room id + store root
uv run syncmill conversations list
uv run syncmill conversations show <run-id>
uv run syncmill conversations close <run-id>
Expand Down
23 changes: 23 additions & 0 deletions docs/architecture/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,29 @@ the store root changes or the store becomes unavailable. Both
views read rows from the snapshot (no artifact read on the event loop), so their
gate/eligibility/usage/blocker wording is the CLI's; the Output view (`o`)
interprets none of it.
Rooms review actions (#174): `j` records a verdict (`RoomCoordinator.judge`,
`surface="tui"`, fenced on the patch digest the dialog showed), `p` promotes a
lesson (`promote_lesson(expected_judgment_id=)`, refused when the latest
judgment is no longer the one the dialog showed), and the `x` round dialog
shows the project's approved lessons (`RoundLessonsView`, read once when it
opens) and recomputes the CLI's `select_lessons(skip=)` on every checkbox,
prompt or lineage change. It refuses instead of dropping lessons when the
prompt leaves no room (`round_prompt` gives the fit check and the launch one
fallback rule), and the worker gets exactly the shown pairs. The round, verdict
and promote dialogs capture `(room id, store root)` when they open; its callback acts on those and
refuses when the store was rebound, never reading the ambient selection. Judge
and promote run on the Textual thread on the existing connections (SQLite is
thread-affine; the board connection must not enter a worker), accepting up to
the stores' 5 s busy timeout of blocking under write contention. The evidence
snapshot also records the room's judgment count, read before the build: a
verdict changes neither revision nor candidates, so `changed` compares the
count too; a failed count read, at the build or now, degrades to a "cannot
tell" line instead of aborting the staged room update. Checkbox labels built from stored text go
through `plain(...)` (Textual parses a `str` label as markup); the
`markup_sinks` guard flags any other non-literal label. A lesson's checkbox
label is only its id: a `Checkbox` label never wraps, so a long lesson would be
cut off at the dialog edge while every word of it is sent. The full text is a
wrapping `PlainStatic` under it.
EVERY poll path is degrade-guarded (`_ROOM_POLL_ERRORS`/`_SESSION_POLL_ERRORS`/
`_JOB_POLL_ERRORS`) — an unguarded read escapes the interval callback and kills
the app, so a store failure must show DEGRADED and keep the last good view.
Expand Down
20 changes: 10 additions & 10 deletions docs/assets/tui/09-rooms-diff.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 10 additions & 10 deletions docs/assets/tui/10-rooms-output.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 12 additions & 12 deletions docs/assets/tui/11-rooms-details.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 12 additions & 12 deletions docs/assets/tui/12-rooms-compare.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 12 additions & 3 deletions docs/user-guide.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -888,8 +888,11 @@ Room 라운드에도 같은 프로젝트 전체 교훈이 들어갑니다. `room
내용이 그대로 남습니다. 교훈을 빼면 다시 보내지 않을 뿐, 이미 보낸 것을 거둘 수는 없습니다.
이전 라운드에 교훈이 있던 방에서 대화를 이어 가는 라운드라면 목록에 `--fresh-session`을
안내하는 문장이 붙습니다. 이 안내는 목록을 보여 준 시점의 방 상태를 기준으로 하므로,
detached worker가 시작하기 전에 끝난 라운드는 반영하지 않습니다. TUI에서 시작한 라운드는 아직 교훈을 보내지 않고 같은 안내를
보여 줍니다. `room compare`는 라운드가 고른 교훈과 후보마다 교훈이 들어갔는지를 보여 줍니다.
detached worker가 시작하기 전에 끝난 라운드는 반영하지 않습니다. TUI에서 시작한 라운드도
교훈을 보냅니다. 라운드 대화상자가 교훈마다 체크박스를 보여 주고(체크 해제 = `--skip-lesson`),
예산 때문에 빠진 교훈을 표시하며, 프롬프트가 체크한 교훈을 담을 자리를 남기지 않으면 교훈을
빼고 진행하는 대신 시작을 거부합니다. 이전 교훈 안내는 고른 lineage에 맞춰 대화상자 안에
나옵니다. `room compare`는 라운드가 고른 교훈과 후보마다 교훈이 들어갔는지를 보여 줍니다.

`room apply --verify`는 다른 `--verify`와 마찬가지로 라운드에 기록된 설정이 아니라 현재
`[gate]`로 검증합니다. 기록을 다시 쓰면 지금 설정한 것보다 넓은 환경이 verify에 넘어갈 수
Expand Down Expand Up @@ -1065,7 +1068,13 @@ Rooms의 `a`는 prompt와 member만 기록하며 provider를 호출하지 않습
잘림 상태). 완료된 후보는 Diff로, 실패한 후보는 Output으로 열려요. `R`은
`room recover`로 **프로젝트 전체**의 죽은 room 워커/중단된 apply를 복구하고,
`k`는 선택한 room을
닫습니다(후보와 영수증은 보존).
닫습니다(후보와 영수증은 보존). room 화면에는 결과(outcome)가 나옵니다. `j`는 적용된 room에
대한 판정을 기록합니다(`room judge`: accepted 또는 rejected를 고르고 이유를 덧붙일 수 있으며,
판정은 대화상자에 보인 patch digest에 묶입니다). `p`는 판정된 room에서 교훈을 승격합니다
(`room promote`, board 필요). 대화상자는 교훈에 기록될 출처를 보여 주고, 열려 있는 동안 판정이
바뀌었으면 거부합니다. `x`의 라운드 대화상자는 프로젝트의 승인된 교훈을 보여 줍니다(Rooms 참고).
이 대화상자들은 그사이 선택이 바뀌어도 처음 연 room에만 적용됩니다. 어디서든 판정이 기록되면
열려 있는 Compare/Details 스냅샷에 바뀌었다는 표시가 붙습니다.

실행은 Textual이 아니라 분리된 백그라운드 매니저가 맡아요. `q`는 화면만 닫고 실행은
계속합니다. 다시 `syncmill tui`를 열면 실행 상태와 로컬 rolling 로그에 재연결돼요.
Expand Down
17 changes: 14 additions & 3 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -964,8 +964,11 @@ conversation keeps whatever earlier rounds sent it: leaving a lesson out stops i
being sent again, it cannot take it back. When a round would resume a conversation
in a room whose earlier rounds had lessons, the list adds a note pointing to
`--fresh-session`. The note reflects the room as of the listing: a round that
finishes before a detached worker starts is not taken into account. Rounds started from the TUI do not send lessons yet and show
the same note. `room compare` shows which lessons a round selected and, per
finishes before a detached worker starts is not taken into account. Rounds started from
the TUI send lessons too: the round dialog lists them with a checkbox each (unchecking is
`--skip-lesson`), names any left out by the budget, and refuses to start, instead of
dropping them, when the prompt leaves no room for the checked ones; the history note
appears in the dialog for the lineage you pick. `room compare` shows which lessons a round selected and, per
candidate, whether they were included.

`room apply --verify` runs the current `[gate]`, like every `--verify`, not the
Expand Down Expand Up @@ -1175,7 +1178,15 @@ code, per-check gate results, files, digests, and truncation state). A
completed candidate opens on Diff; a failed one opens on Output. `R` recovers
dead room workers/interrupted applies for the whole project via `room recover`,
and `k` closes the
selected room (candidates and receipts are preserved).
selected room (candidates and receipts are preserved). The room pane shows its
outcome. `j` records your verdict on an applied room (`room judge`: you pick
accepted or rejected, add an optional rationale, and the verdict is bound to the
patch digest the dialog showed). `p` promotes a lesson from a judged room
(`room promote`, needs the board); the dialog shows the source the lesson will
record and refuses if the verdict changed while it was open. `x` lists the
project's approved lessons in the round dialog (see Rooms). These dialogs act on
the room they were opened for, even if the selection moves meanwhile. A verdict
recorded anywhere marks an open Compare or Details snapshot as changed.

Runs are owned by a detached manager, not Textual. Only one TUI-managed worker per
board is allowed, but ordinary external `board run` processes remain legal. Pressing
Expand Down
24 changes: 23 additions & 1 deletion src/syncmill/rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2959,6 +2959,15 @@ def list_judgments(self, room_id: str) -> list[dict[str, Any]]:
).fetchall()
return [dict(row) for row in rows]

def judgment_count(self, room_id: str) -> int:
"""How many judgment rows the room holds: a cheap freshness signal for
a view built from an earlier read (a verdict changes neither the
room's revision nor its candidates)."""
row = self._conn.execute(
"SELECT COUNT(*) FROM room_judgments WHERE room_id=?", (room_id,)
).fetchone()
return int(row[0])

def outcome(self, room_id: str) -> OutcomeCheck:
"""The room's human outcome, read in one snapshot."""
with self._read_snapshot() as conn:
Expand Down Expand Up @@ -4242,13 +4251,18 @@ def promote_lesson(
*,
scope: str = "",
supersedes: str | None = None,
expected_judgment_id: str | None = None,
) -> tuple[Any, bool]:
"""Promote a short, human-written lesson from a judged room into the
board's decisions, bound to this project (#174).

Only a room whose latest judgment binds (accepted or rejected) can be
promoted: the lesson's provenance names that judgment, and a retry of
the same promotion returns the decision it already created."""
the same promotion returns the decision it already created.
``expected_judgment_id`` refuses the promotion unless that judgment is
still the latest, checked on the same read the provenance comes from,
so a verdict recorded after the person reviewed it is never promoted
in its name."""
text = normalize_lesson(lesson)
if not isinstance(scope, str):
raise RoomError("lesson scope must be text")
Expand All @@ -4263,6 +4277,14 @@ def promote_lesson(
f"room outcome is {outcome.status}{detail}; record a verdict with "
"`syncmill room judge` before promoting a lesson"
)
if (
expected_judgment_id is not None
and judgment["judgment_id"] != expected_judgment_id
):
raise RoomError(
"the room's verdict changed since you reviewed it; review the "
"promotion again"
)
provenance = {
"kind": "room-judgment",
"room_id": room_id,
Expand Down
Loading
Loading