@andresmassello/uscha 1.87.0 → 1.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,8 +13,12 @@ Truth-pass (INV-TOP-05): a field the engine emits as null renders as an em dash,
13
13
  zero and never as a guess. In v0.1 that is ETA, every AGE, drift, and the trace column --
14
14
  each with its deferred wiring recorded in ADR-035.
15
15
 
16
- M1 scope: the read-only BOARD. The live feed (M2) and VERDICTS mode (M3) are not wired; the
17
- panes that will hold them are labelled as such rather than faked.
16
+ M3 scope: the read-only BOARD, the live feed and its mtime poll, and VERDICTS mode -- the
17
+ ONE thing this application writes. A verdict is written by shelling out to the engine's own
18
+ `qa_ledger.py curate`, one process per keypress, one observation per process (ADR-033): the
19
+ TUI never opens the ledger for writing and never builds a curation record, so it cannot
20
+ drift from the record shape the engine owns. It records a judgement; it does not promote,
21
+ does not rerun, and never moves DONE (INV-TOP-03).
18
22
 
19
23
  Stdlib only. Python 3.8+. Runnable directly or via `python -m uscha_top`.
20
24
  """
@@ -25,6 +29,7 @@ import os
25
29
  import shutil
26
30
  import subprocess
27
31
  import sys
32
+ import time
28
33
 
29
34
  DEFAULT_LEDGER = "QA-LEDGER.json"
30
35
  FALLBACK_SIZE = (100, 32)
@@ -32,8 +37,29 @@ FALLBACK_SIZE = (100, 32)
32
37
  # Lines the board always spends on chrome: the title, 3 rules, 4 KPI lines, the table
33
38
  # header, the feed label and the key hint. Everything else is table rows + feed.
34
39
  CHROME_LINES = 11
35
- FEED_MAX = 3
40
+ FEED_MAX = 8 # = the engine's events_tail length; a short terminal shows fewer
36
41
  BURNUP_MAX = 24
42
+ MIN_REFRESH = 0.5 # a poll faster than this is a busy loop, not a refresh
43
+
44
+ MODE_BOARD = "board"
45
+ MODE_VERDICTS = "verdicts"
46
+ # VERDICTS geometry: title, rule, the pending line, the rule under the list, the rule under
47
+ # the pane, the status line, the key hint. Everything else is queue rows + the detail pane.
48
+ VERDICT_CHROME = 7
49
+ VERDICT_LIST_MAX = 9 # [1]..[9]: exactly the observations a single keypress can select
50
+ SIDE_BY_SIDE_MIN = 100 # narrower than this, candidate and evidence stack instead of pairing
51
+ # The three verdicts `curate` accepts, and nothing else: the vocabulary belongs to ADR-013.
52
+ VERDICTS = {"p": "preserve", "f": "fix", "u": "undefined"}
53
+ CURATE_NOTE = "recorded via uscha top"
54
+ VERDICT_HINT = "the only write is a verdict, recorded by `qa_ledger.py curate`"
55
+ # A held key repeats. Because the queue ADVANCES after every write, repeat number two would
56
+ # land on an observation the human never read -- N verdicts from one glance, which is the
57
+ # batch INV-CURATION-01 forbids arriving one legitimate call at a time. Two guards: the input
58
+ # buffer is drained after a write (`drain_keys`), and for this long a verdict key is refused
59
+ # outright, saying so instead of swallowing it.
60
+ VERDICT_COOLDOWN = 0.25
61
+ VERDICT_COOLDOWN_MSG = ("verdict recorded -- release the key (the queue advanced; the next "
62
+ "observation is a new judgement)")
37
63
 
38
64
  # ANSI SGR by obligation state. TRACED and TAGGED deliberately share the UNMEASURED gray:
39
65
  # the v0.1 engine has no source for either rung (ADR-032), so they must read as "not
@@ -52,6 +78,19 @@ MID = "·"
52
78
  RULE = "─"
53
79
  BLOCKS = "▁▂▃▄▅▆▇█"
54
80
 
81
+ # Feed levels: one letter and one colour each. The LETTER carries the level on the plain
82
+ # path (golden frames, pipes, CI) and the colour only decorates that same letter on a real
83
+ # terminal -- so both paths have identical geometry and a snapshot compares text, never
84
+ # terminal control codes. `info` is deliberately uncoloured: it is the level an unclassified
85
+ # step falls back to, and it must not look like a verdict.
86
+ FEED_LEVELS = {
87
+ "pass": ("P", "32"),
88
+ "fail": ("F", "31"),
89
+ "human": ("H", "33"),
90
+ "unmeasured": ("U", "90"),
91
+ "info": ("I", ""),
92
+ }
93
+
55
94
  # What the reader is expected to DO about a row. Presentation, not a KPI: no number here.
56
95
  ACTIONS = {
57
96
  "MEASURED_PASS": DASH,
@@ -121,12 +160,16 @@ def _burnup_line(burnup, cols):
121
160
  def _spec_pin_text(spec_pin):
122
161
  """git HEAD, labelled for what it is. There is no pinned-spec concept in the engine yet
123
162
  (ADR-035/4): an unverified sha must SAY it is unverified, and a non-git tree shows the
124
- em dash rather than a fabricated pin (AC-T-06, INV-TOP-05)."""
163
+ em dash rather than a fabricated pin (AC-T-06, INV-TOP-05).
164
+
165
+ The sha is state-supplied text like any other, so it goes through `_safe`: it shares a
166
+ line with no colour of its own, but a frozen state carrying an escape here would put one
167
+ in the header, and the header is the one line every frame has."""
125
168
  if not spec_pin or not spec_pin.get("sha"):
126
169
  return "spec_pin " + DASH
127
170
  mark = ("clean-room verified" if spec_pin.get("clean_room_verified")
128
171
  else "not clean-room verified")
129
- return "spec_pin %s (%s)" % (spec_pin["sha"], mark)
172
+ return "spec_pin %s (%s)" % (_safe(spec_pin["sha"]), mark)
130
173
 
131
174
 
132
175
  def _cases_text(ob):
@@ -139,11 +182,30 @@ def _cases_text(ob):
139
182
  def _row(ob, selected):
140
183
  gutter = "> " if selected else " "
141
184
  return "%s%-8s%-9s%-15s%7s%5s %s" % (
142
- gutter, str(ob.get("id") or "?")[:8], str(ob.get("gate") or DASH)[:8],
143
- str(ob.get("state") or "?")[:14], _cases_text(ob),
185
+ gutter, _safe(ob.get("id") or "?")[:8], _safe(ob.get("gate") or DASH)[:8],
186
+ _safe(ob.get("state") or "?")[:14], _cases_text(ob),
144
187
  _num(ob.get("age_hours")), ACTIONS.get(ob.get("state"), DASH))
145
188
 
146
189
 
190
+ def _safe(text):
191
+ """No control character reaches the terminal through the feed. The engine already
192
+ strips them where the text is derived (`_top_event_text`); this is the second guard on
193
+ the same surface, because the renderer also accepts a frozen state file a human wrote,
194
+ and one ESC in it would be a control sequence the board obeys instead of prints."""
195
+ return "".join(c for c in str(text or "") if ord(c) >= 32 and ord(c) != 127)
196
+
197
+
198
+ def _feed_line(ev, cols, plain):
199
+ """`HH:MM:SS L text` -- the level letter is the level, the colour only decorates it,
200
+ so the plain frame carries exactly the same information as the coloured one."""
201
+ letter, sgr = FEED_LEVELS.get(ev.get("level"), FEED_LEVELS["info"])
202
+ line = _fit(" %s %s %s" % (_safe(ev.get("ts")) or DASH, letter,
203
+ _safe(ev.get("text"))), cols)
204
+ if plain or not sgr:
205
+ return line
206
+ return line.replace(" %s " % letter, " \x1b[%sm%s%s " % (sgr, letter, RESET), 1)
207
+
208
+
147
209
  def _colorize(line, state):
148
210
  code = PALETTE.get(state)
149
211
  if not code or state not in line:
@@ -151,14 +213,23 @@ def _colorize(line, state):
151
213
  return line.replace(state, "\x1b[%sm%s%s" % (code, state, RESET), 1)
152
214
 
153
215
 
154
- def render(state, size, sel=0, plain=True):
155
- """The whole board as a list of exactly `rows` lines, none wider than `cols`.
216
+ def render(state, size, sel=0, plain=True, mode=MODE_BOARD, status=""):
217
+ """One frame: exactly `rows` lines, none wider than `cols`.
156
218
 
157
219
  PURE (ADR-034): no I/O, no clock, no randomness, no environment. `state` is the parsed
158
- `qa_ledger.py top --json` object; `size` is (cols, rows); `sel` is the highlighted row.
159
- plain=True emits no escape sequences at all -- the mode `--once`, CI and the golden
160
- frames use, so a snapshot compares text and not terminal control codes.
220
+ `qa_ledger.py top --json` object; `size` is (cols, rows); `sel` is the highlighted row of
221
+ the ACTIVE mode; `mode` picks the board or the verdicts queue; `status` is the last line
222
+ the write path produced (a parameter, not a global, so the frame stays a pure function of
223
+ its inputs and the golden frames stay reproducible). plain=True emits no escape sequences
224
+ at all -- the mode `--once`, CI and the golden frames use, so a snapshot compares text and
225
+ not terminal control codes.
161
226
  """
227
+ if mode == MODE_VERDICTS:
228
+ return _render_verdicts(state, size, sel, plain, status)
229
+ return _render_board(state, size, sel, plain, status)
230
+
231
+
232
+ def _render_board(state, size, sel, plain, status=""):
162
233
  cols, rows = size
163
234
  cols = max(20, int(cols))
164
235
  rows = max(CHROME_LINES + 1, int(rows))
@@ -167,9 +238,14 @@ def render(state, size, sel=0, plain=True):
167
238
  debtors = state.get("debtors") or {}
168
239
  honesty = state.get("honesty") or {}
169
240
 
241
+ # every string the STATE supplies goes through _safe on its way into a line (project,
242
+ # spec_pin, the row cells, the feed): after that the only escapes in a frame are the
243
+ # ones this renderer put there, which is what lets the final width pass leave coloured
244
+ # lines alone without a state file being able to smuggle one in (or widen a line).
170
245
  out = []
171
- out.append(_spread("uscha top %s %s" % (MID, state.get("project") or "(unnamed project)"),
172
- "step #%s" % _num(state.get("step")), cols))
246
+ out.append(_spread("uscha top %s %s"
247
+ % (MID, _safe(state.get("project")) or "(unnamed project)"),
248
+ "step #%s" % _safe(_num(state.get("step"))), cols))
173
249
  out.append(RULE * cols)
174
250
  out.append(_pct_line(terminado))
175
251
  out.append("machine owes %s %s you owe %s %s untagged %s %s ETA %s"
@@ -177,9 +253,12 @@ def render(state, size, sel=0, plain=True):
177
253
  _num(debtors.get("untagged")), MID, _num(state.get("eta_min"))))
178
254
  # honesty travels BESIDE done on purpose (INV-TOP-04): a thin denominator has to be
179
255
  # visible at the same glance as the number it flatters.
180
- out.append("honesty %s/%s (%s%%) measured %s %s"
181
- % (_num(honesty.get("measured")), _num(honesty.get("total")),
182
- _num(honesty.get("pct")), MID, _spec_pin_text(state.get("spec_pin"))))
256
+ # fitted HERE, at construction, not only by the pass at the end: this line carries the
257
+ # longest state-supplied string of the header, and the end pass skips coloured lines.
258
+ out.append(_fit("honesty %s/%s (%s%%) measured %s %s"
259
+ % (_num(honesty.get("measured")), _num(honesty.get("total")),
260
+ _num(honesty.get("pct")), MID,
261
+ _spec_pin_text(state.get("spec_pin"))), cols))
183
262
  out.append(_burnup_line(state.get("burnup"), cols))
184
263
  out.append(RULE * cols)
185
264
  out.append(" %-8s%-9s%-15s%7s%5s %s"
@@ -214,22 +293,194 @@ def render(state, size, sel=0, plain=True):
214
293
  pad = avail - len(table[:max(1, table_n)]) - feed_n
215
294
  out.extend([""] * max(0, pad))
216
295
  out.append(RULE * cols)
217
- events = state.get("events_tail") or []
218
- out.append("feed %s the live event tail is M2; nothing is invented here" % MID)
296
+ events = [e for e in (state.get("events_tail") or []) if isinstance(e, dict)]
297
+ shown = events[:feed_n]
298
+ if not events:
299
+ # honest empty label: a ledger with no steps has nothing to feed, and saying so is
300
+ # not the same statement as an idle feed with the lines scrolled away (INV-TOP-05).
301
+ out.append("feed %s no ledger step recorded yet (nothing to show)" % MID)
302
+ elif not shown:
303
+ # the board is served first (AC-T-21), so at the 80x24 floor with a long table the
304
+ # feed can lose every line. It says so; it does not pretend the ledger is quiet.
305
+ out.append("feed %s 0/%d %s no room at this size (the board is served first)"
306
+ % (MID, len(events), MID))
307
+ else:
308
+ # `3/8` says out loud that the pane is showing three of the eight steps the engine
309
+ # sent: a feed that silently drops lines is a feed that can hide the red one.
310
+ out.append("feed %s %d/%d %s newest first %s P/F/H/U/I = pass/fail/human/"
311
+ "unmeasured/info" % (MID, len(shown), len(events), MID, MID))
312
+ if status:
313
+ # the LAST verdict of a queue empties it and drops the reader back here, so the
314
+ # engine's own confirmation would otherwise vanish with the mode that showed it. It
315
+ # takes the feed's label line for exactly one frame (the next keypress clears it) --
316
+ # the feed's own `N/M` count returns with it. `status` is empty on every other path,
317
+ # which is why the golden frames never see this line.
318
+ out[-1] = _fit("status %s %s" % (MID, _safe(status)), cols)
219
319
  for i in range(feed_n):
220
- if i < len(events):
221
- ev = events[i]
222
- out.append(_fit(" %s %s" % (ev.get("ts") or DASH, ev.get("text") or ""), cols))
223
- else:
224
- out.append("")
225
- out.append("[j/k] move %s [r] reload %s [q] quit %s [v] verdicts (M3) %s "
320
+ out.append(_feed_line(shown[i], cols, plain) if i < len(shown) else "")
321
+ # `[v] verdicts` lost its `(M3)` marker in 1.89.0 because the key now works; `[d]/[o]`
322
+ # keeps its `phase 2` marker because those two still do nothing (SPEC s1/s6). A hint that
323
+ # labels a live key as future is the same class of stale claim the frames exist to catch.
324
+ out.append("[j/k] move %s [r] reload %s [q] quit %s [v] verdicts %s "
226
325
  "[d]/[o] phase 2" % (MID, MID, MID, MID))
227
- out = [_fit(line, cols) for line in out]
326
+ # a coloured line was already fitted BEFORE its escape bytes went in (table rows and
327
+ # feed lines both), and re-fitting it here would count those bytes as visible width --
328
+ # cutting the coloured frame ~9 characters shorter than the plain one it is supposed to
329
+ # match. Fit only what carries no escapes; the golden frames are that path exactly.
330
+ out = [line if "\x1b" in line else _fit(line, cols) for line in out]
228
331
  # exactly `rows` lines: a frame that drifts in height is a frame no snapshot can pin
229
332
  out = out[:rows] + [""] * max(0, rows - len(out))
230
333
  return out
231
334
 
232
335
 
336
+ # --------------------------------------------------------------------------- #
337
+ # VERDICTS mode -- the queue, the detail pane, and the keymap that writes #
338
+ # --------------------------------------------------------------------------- #
339
+ def verdict_queue(state):
340
+ """The pending queue is EXACTLY what the engine emitted. `observations[]` already holds
341
+ only uncurated observations, in the order `cmd_top` fixed (the anchored criterion first,
342
+ then the id). The TUI filters nothing and sorts nothing -- a second place that decides
343
+ what is pending is a second place that can disagree with the ledger (ADR-032)."""
344
+ return [o for o in (state.get("observations") or []) if isinstance(o, dict)]
345
+
346
+
347
+ def _wrap(text, width):
348
+ """Whole words onto as many lines as they need.
349
+
350
+ The pane must never cut a claim in half: a claim the reader cannot finish is a verdict
351
+ recorded on half the evidence (AC-T-14). A single token wider than the pane is hard-split
352
+ and CONTINUES on the next line, so nothing is dropped either way."""
353
+ width = max(8, int(width))
354
+ out, line = [], ""
355
+ for word in _safe(text).split():
356
+ if not line:
357
+ line = word
358
+ elif len(line) + 1 + len(word) <= width:
359
+ line += " " + word
360
+ else:
361
+ out.append(line)
362
+ line = word
363
+ while len(line) > width:
364
+ out.append(line[:width])
365
+ line = line[width:]
366
+ if line:
367
+ out.append(line)
368
+ return out or [""]
369
+
370
+
371
+ def _obs_row(i, ob, selected, cols):
372
+ """One queue line: `[n] OBS-id title · AC-x · pending`. The TITLE is the engine's
373
+ capped head of the claim; the whole claim lives in the pane below, never here."""
374
+ gutter = "> " if selected else " "
375
+ idx = "[%d]" % (i + 1) if i < VERDICT_LIST_MAX else " "
376
+ tail = " %s %s %s pending" % (MID, _safe(ob.get("ac")) or ("AC " + DASH), MID)
377
+ head = "%s%-4s%-18s" % (gutter, idx, _safe(ob.get("id"))[:18])
378
+ room = max(4, cols - len(head) - len(tail))
379
+ title = _fit(_safe(ob.get("title")) or DASH, room)
380
+ return head + title.ljust(room) + tail
381
+
382
+
383
+ def _column_widths(cols):
384
+ """` <left> │ <right>` spends 2 on the gutter and 3 on the divider."""
385
+ left = (cols - 5) // 2
386
+ return left, cols - 5 - left
387
+
388
+
389
+ def _pane(ob, cols, height):
390
+ """The detail of the selected observation, in exactly `height` lines.
391
+
392
+ Side by side while there is room, stacked below `SIDE_BY_SIDE_MIN` columns -- a
393
+ 40-character column is not a pane, it is a word per line. Content that still does not fit
394
+ is NOT silently cut: the last line says how many lines are missing, which is the same
395
+ discipline the feed's `5/7` label follows."""
396
+ if height <= 0:
397
+ return []
398
+ if not ob:
399
+ body = [" no observation selected %s the queue is empty ([t] returns to the board)"
400
+ % MID]
401
+ else:
402
+ head = [" %s %s %s %s repo %s" % (_safe(ob.get("id")) or "?", MID,
403
+ _safe(ob.get("ac")) or ("AC " + DASH), MID,
404
+ _safe(ob.get("repo")) or DASH), ""]
405
+ def block(key, width):
406
+ return [ln for x in (ob.get(key) or []) for ln in _wrap(x, width)]
407
+
408
+ if cols >= SIDE_BY_SIDE_MIN:
409
+ lw, rw = _column_widths(cols)
410
+ left = ["CANDIDATE"] + block("candidate", lw)
411
+ right = ["EVIDENCE"] + block("evidence", rw)
412
+ pad = max(len(left), len(right))
413
+ left += [""] * (pad - len(left))
414
+ right += [""] * (pad - len(right))
415
+ body = head + [(" %s │ %s" % (l.ljust(lw), r)).rstrip()
416
+ for l, r in zip(left, right)]
417
+ else:
418
+ body = (head + [" CANDIDATE"] + [" " + ln for ln in block("candidate", cols - 2)]
419
+ + [""] + [" EVIDENCE"] + [" " + ln for ln in block("evidence", cols - 2)])
420
+ if len(body) > height:
421
+ body = body[:height - 1] + [" %s %d more line(s) of this observation do not fit at "
422
+ "this size" % (DASH, len(body) - (height - 1))]
423
+ return [_fit(ln, cols) for ln in body] + [""] * max(0, height - len(body))
424
+
425
+
426
+ def _render_verdicts(state, size, sel, plain, status):
427
+ """The verdicts queue. Read-only like every other frame -- the write happens in the
428
+ dispatch, never in the renderer (ADR-034: `render` performs no I/O at all).
429
+
430
+ `plain` is accepted and not used: this frame carries no colour of its own (the `>` gutter
431
+ marks the selection, and a state colour here would decorate a claim rather than a
432
+ verdict), so the coloured and plain paths are the same lines. Keeping the parameter keeps
433
+ one render signature, and keeps the golden frames comparing the frame the terminal draws."""
434
+ cols, rows = size
435
+ cols = max(20, int(cols))
436
+ rows = max(VERDICT_CHROME + 4, int(rows))
437
+ queue = verdict_queue(state)
438
+ debtors = state.get("debtors") or {}
439
+ sel = max(0, min(int(sel), max(0, len(queue) - 1)))
440
+
441
+ out = [_spread("uscha top %s %s %s verdicts"
442
+ % (MID, _safe(state.get("project")) or "(unnamed project)", MID),
443
+ "step #%s" % _safe(_num(state.get("step"))), cols),
444
+ RULE * cols,
445
+ # the two numbers are DIFFERENT facts and both are named: `pending` counts
446
+ # uncurated observations, `you owe` counts the criteria they hold in quarantine.
447
+ # One observation can name no criterion at all, so conflating them would inflate
448
+ # whichever is shown alone.
449
+ _fit("pending %d %s you owe %s %s a verdict never moves DONE (INV-TOP-03)"
450
+ % (len(queue), MID, _num(debtors.get("you")), MID), cols)]
451
+
452
+ avail = rows - VERDICT_CHROME
453
+ want = len(queue) or 1
454
+ list_n = max(1, min(VERDICT_LIST_MAX, want, avail - 3))
455
+ body = list_n - 1 if len(queue) > list_n else list_n
456
+ body = max(1, body)
457
+ top = 0
458
+ if sel >= body:
459
+ top = min(sel - body + 1, max(0, len(queue) - body))
460
+ rowsout = [_fit(_obs_row(i, ob, i == sel, cols), cols)
461
+ for i, ob in enumerate(queue[top:top + body], start=top)]
462
+ if len(queue) > len(rowsout):
463
+ rowsout.append(_fit(" %s %d more observation(s) not shown (j/k to move)"
464
+ % (DASH, len(queue) - len(rowsout)), cols))
465
+ if not queue:
466
+ rowsout = [_fit(" nothing uncurated %s every observation carries a verdict "
467
+ "(`promote` is a human step, not this one)" % MID, cols)]
468
+ out.extend(rowsout[:list_n])
469
+ out.extend([""] * max(0, list_n - len(rowsout)))
470
+
471
+ out.append(RULE * cols)
472
+ out.extend(_pane(queue[sel] if queue else None, cols, avail - list_n))
473
+ out.append(RULE * cols)
474
+ out.append(_fit("status %s %s" % (MID, _safe(status) or VERDICT_HINT), cols))
475
+ # every key this mode answers to is on the line, `[r]` included: the queue is re-read from
476
+ # a ledger another process can move, and a reload the reader cannot find is a reload that
477
+ # does not exist. Abbreviated to fit the 80-column floor without the `…` cut.
478
+ out.append(_fit("[jk/1-9] move %s [p]reserve %s [f]ix %s [u]ndefined %s [r]eload %s "
479
+ "[t] back %s [q]uit" % (MID, MID, MID, MID, MID, MID), cols))
480
+ out = [line if "\x1b" in line else _fit(line, cols) for line in out]
481
+ return out[:rows] + [""] * max(0, rows - len(out))
482
+
483
+
233
484
  # --------------------------------------------------------------------------- #
234
485
  # state loading (the ONE read boundary -- it shells out, it never re-derives) #
235
486
  # --------------------------------------------------------------------------- #
@@ -313,6 +564,218 @@ def read_key():
313
564
  termios.tcsetattr(fd, termios.TCSADRAIN, saved)
314
565
 
315
566
 
567
+ def wait_key(timeout):
568
+ """One keypress, or "" when `timeout` seconds pass first. This is what makes the poll
569
+ possible without a busy loop AND without a key that waits for the next tick to be seen:
570
+ POSIX blocks in `select` (raw mode held for the whole window, so a single byte is
571
+ readable the instant it arrives), Windows walks `msvcrt.kbhit` in short slices."""
572
+ if os.name == "nt":
573
+ import msvcrt
574
+ deadline = time.time() + max(0.0, timeout)
575
+ while True:
576
+ if msvcrt.kbhit():
577
+ return read_key()
578
+ if time.time() >= deadline:
579
+ return ""
580
+ time.sleep(0.03)
581
+ import select
582
+ import termios
583
+ import tty
584
+ fd = sys.stdin.fileno()
585
+ try:
586
+ saved = termios.tcgetattr(fd)
587
+ except Exception:
588
+ return "" # no terminal to read: never block
589
+ try:
590
+ tty.setraw(fd)
591
+ ready, _, _ = select.select([sys.stdin], [], [], max(0.0, timeout))
592
+ return sys.stdin.read(1) if ready else ""
593
+ finally:
594
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
595
+
596
+
597
+ DRAIN_MAX = 256 # a terminal that never stops reporting input is not drained forever
598
+
599
+
600
+ def drain_keys():
601
+ """Throw away whatever is ALREADY in the input buffer, and say how much it threw.
602
+
603
+ Called right after a verdict. `wait_key` reads one byte per turn of the loop, so a held
604
+ key (or a fast repeat, or a paste) leaves N keypresses queued -- and because the queue
605
+ advances after every write, keypress two would judge the observation that just moved into
606
+ the cursor's place. Draining is what makes "one keypress, one verdict" true of the
607
+ KEYBOARD and not only of the dispatch (ADR-033, INV-CURATION-01).
608
+
609
+ Same family as `read_key`/`wait_key` and isolated for the same reason: the driver is not
610
+ what the suite tests, so it must be replaceable. Without a terminal it drops nothing and
611
+ returns 0 rather than raising -- a pipe has no held key to drain."""
612
+ dropped = 0
613
+ if os.name == "nt":
614
+ try:
615
+ import msvcrt
616
+ while dropped < DRAIN_MAX and msvcrt.kbhit():
617
+ msvcrt.getch()
618
+ dropped += 1
619
+ except Exception:
620
+ return dropped # no console: nothing was buffered
621
+ return dropped
622
+ import select
623
+ import termios
624
+ import tty
625
+ fd = sys.stdin.fileno()
626
+ try:
627
+ saved = termios.tcgetattr(fd)
628
+ except Exception:
629
+ return 0 # no terminal: nothing to drain
630
+ try:
631
+ tty.setraw(fd)
632
+ while dropped < DRAIN_MAX and select.select([sys.stdin], [], [], 0)[0]:
633
+ if not sys.stdin.read(1):
634
+ break # EOF reads ready forever: stop
635
+ dropped += 1
636
+ finally:
637
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
638
+ return dropped
639
+
640
+
641
+ def _changed(paths, seen):
642
+ """(changed?, new snapshot) for a set of files, by (mtime, size).
643
+
644
+ The whole of the M2 poll: no server, no watcher, no thread (ADR-031). Kept as a small
645
+ pure-ish function on purpose -- it is the piece the suite can actually drive (AC-T-12),
646
+ while a real TTY session is not. A path that cannot be stat'ed records None instead of
647
+ raising: a ledger deleted under the app is a CHANGE, not a crash."""
648
+ now = {}
649
+ for path in paths or []:
650
+ if not path:
651
+ continue
652
+ try:
653
+ st = os.stat(path)
654
+ now[path] = (st.st_mtime, st.st_size)
655
+ except OSError:
656
+ now[path] = None
657
+ return now != (seen if seen is not None else {}), now
658
+
659
+
660
+ def watch_paths(args):
661
+ """What the poll watches: the frozen state file when one is given, otherwise the ledger
662
+ the engine reads. Nothing else -- `discovery/CANDIDATE-DELTA.json` is NOT watched in
663
+ v0.1 (the state carries no path to it), so a `discover` run that leaves the ledger
664
+ untouched is seen on the next `r`, not on the next tick. Under-claim, then wire."""
665
+ return [args.state] if getattr(args, "state", None) else [getattr(args, "ledger", None)]
666
+
667
+
668
+ def resolve_human(explicit=None):
669
+ """Who is at the keyboard. The person recording the verdict is its author, so the TUI
670
+ passes the name EXPLICITLY (ADR-033) instead of letting the engine guess in a different
671
+ process -- an SSH or multi-user session would otherwise attribute the judgement to
672
+ whoever owns the environment. It never invents one: with nothing to resolve this returns
673
+ None, `--human` is left off the call, and `curate`'s own default stands."""
674
+ return explicit or os.environ.get("USERNAME") or os.environ.get("USER") or None
675
+
676
+
677
+ def _curate_call(engine, ledger, repo, obs_id, verdict, human=None, note=CURATE_NOTE):
678
+ """THE single write of this application (ADR-033): one process, one observation, one
679
+ verdict. The TUI never opens the ledger for writing and never constructs a curation
680
+ record -- the record shape belongs to `curate` (ADR-013), which is exactly what the
681
+ byte-equal fixture (AC-T-17) measures.
682
+
683
+ It is one function on purpose: it is the boundary the suite replaces to assert the argv
684
+ and the ONE call per keypress without writing anything (AC-T-15).
685
+
686
+ Returns (returncode, the engine's own last line)."""
687
+ argv = [sys.executable, engine, "curate", "--ledger", ledger, "--repo", repo,
688
+ "--obs", obs_id, "--verdict", verdict]
689
+ if human:
690
+ argv += ["--human", human]
691
+ argv += ["--note", note]
692
+ proc = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
693
+ said = ((proc.stderr or b"").decode("utf-8", "replace").strip().splitlines()
694
+ or (proc.stdout or b"").decode("utf-8", "replace").strip().splitlines())
695
+ return proc.returncode, (said[-1] if said else "")
696
+
697
+
698
+ def apply_verdict(ob, verdict, args, engine=None):
699
+ """One keypress -> one `curate` call, synchronously, for the ONE selected observation.
700
+
701
+ A refusal by the engine (an unknown OBS, a malformed delta, a batch-looking id) comes
702
+ back as the engine's OWN line and is surfaced: the selection does not advance and nothing
703
+ is retried. A retry loop over a refusal is how a batch gets written one call at a time,
704
+ which is the thing INV-CURATION-01 exists to make impossible.
705
+
706
+ Returns (recorded?, the line the status bar shows)."""
707
+ if getattr(args, "state", None):
708
+ # `--state` renders a FROZEN snapshot: the ledger on disk is not the one on screen (it
709
+ # may be another project's, or none at all). A verdict recorded from it would judge an
710
+ # observation the reader is not looking at -- refused, and named.
711
+ return False, "--state is a frozen snapshot -- verdicts need a live ledger"
712
+ if not ob or not ob.get("id"):
713
+ return False, "no observation selected: nothing to record"
714
+ if not ob.get("repo"):
715
+ return False, ("%s carries no repo in `top --json` -- curate needs one (--repo)"
716
+ % ob.get("id"))
717
+ eng = engine or engine_path()
718
+ if not eng:
719
+ return False, "qa_ledger.py not found next to uscha_top.py"
720
+ rc, said = _curate_call(eng, args.ledger, ob["repo"], ob["id"], verdict,
721
+ getattr(args, "human", None))
722
+ if rc == 0:
723
+ return True, said or ("%s = %s recorded" % (ob["id"], verdict))
724
+ return False, said or ("curate exited %s -- nothing was recorded" % rc)
725
+
726
+
727
+ def after_verdict(sel, count):
728
+ """Where the cursor lands once the queue has been re-read: (selection, mode).
729
+
730
+ The observation just judged is GONE from `observations[]` (the engine emits only
731
+ uncurated ones), so the next pending observation has taken its index -- the selection
732
+ stays put and only clamps at the end. An empty queue is the signal to go back to the
733
+ board: there is nothing left to judge, and a verdicts pane over an empty queue invites a
734
+ second verdict on nothing."""
735
+ if count <= 0:
736
+ return 0, MODE_BOARD
737
+ return max(0, min(sel, count - 1)), MODE_VERDICTS
738
+
739
+
740
+ def dispatch_mode(key, mode, sel, count, cooling=False):
741
+ """The mode machine: key + current mode -> (mode, selection, quit?, reload?, verdict).
742
+
743
+ Pure, and the ONE place a keypress becomes a write decision -- `verdict` is a string the
744
+ caller then spends on exactly one `curate` call, never a loop. The BOARD keymap is
745
+ `dispatch` below, unchanged and still measured on its own, so nothing about the board's
746
+ keys moved when this was layered on top. `sel` belongs to the ACTIVE mode; a mode change
747
+ hands back 0 and the caller keeps the other mode's cursor.
748
+
749
+ `cooling` is the caller's answer to "is a verdict still echoing?" (it owns the clock; this
750
+ stays pure). While it is true, `p`/`f`/`u` produce NO verdict: a key held down repeats,
751
+ and the second repeat would judge the observation that just took the cursor's place. Every
752
+ other key keeps working -- the cooldown blocks writes, not the reader."""
753
+ if mode != MODE_VERDICTS:
754
+ if key in ("v", "V"):
755
+ return MODE_VERDICTS, 0, False, False, None
756
+ sel, quit_now, reload_now = dispatch(key, sel, count)
757
+ return MODE_BOARD, sel, quit_now, reload_now, None
758
+ if key in ("q", "Q", "\x03"):
759
+ return mode, sel, True, False, None
760
+ if key in ("t", "T", "\x1b"):
761
+ return MODE_BOARD, 0, False, False, None
762
+ if key == "j":
763
+ return mode, min(sel + 1, max(0, count - 1)), False, False, None
764
+ if key == "k":
765
+ return mode, max(0, sel - 1), False, False, None
766
+ if key == "r":
767
+ return mode, sel, False, True, None
768
+ if key in VERDICTS:
769
+ # an empty queue produces NO verdict: there is nothing selected to judge, and a
770
+ # keypress that writes anyway would be a verdict the human never aimed at an OBS.
771
+ # Neither does a queue still cooling from the last one.
772
+ return mode, sel, False, False, (VERDICTS[key] if (count and not cooling) else None)
773
+ if len(str(key)) == 1 and key in "123456789":
774
+ n = int(key) - 1
775
+ return mode, (n if n < count else sel), False, False, None
776
+ return mode, sel, False, False, None
777
+
778
+
316
779
  def dispatch(key, sel, count):
317
780
  """Key -> (new selection, quit?, reload?). Pure, so the keymap is testable without a
318
781
  terminal: the driver below is not what is under test, this dispatch is (ADR-034)."""
@@ -339,20 +802,101 @@ def _print_frame(lines):
339
802
  sys.stdout.flush()
340
803
 
341
804
 
805
+ def _reload(state, args):
806
+ """Re-read, or keep what is on screen. A poll that catches the ledger MID-WRITE reads a
807
+ truncated file; the last good board plus a retry next tick is honest, a traceback over
808
+ a working terminal is not."""
809
+ try:
810
+ return load_state(args.state, args.ledger)
811
+ except (OSError, ValueError, RuntimeError):
812
+ return state
813
+
814
+
815
+ def _apply_and_advance(state, args, queue, cur, verdict):
816
+ """ONE keypress -> ONE curate process -> re-read. Returns (state, sel, mode, status, wrote?).
817
+
818
+ This lives in a function of its own, and not as four lines inside the key loop, for a
819
+ reason the suite asserts structurally (AC-T-15): the module's single call to
820
+ `apply_verdict` must have no `for` or `while` above it, so no later edit can quietly turn
821
+ one keypress into a pass over the queue. The re-read afterwards is a READ -- the verdict
822
+ left the queue and the board behind it did not move (INV-TOP-03); nothing reruns.
823
+
824
+ The input buffer is drained whether the write landed or not: a held key queues repeats
825
+ either way, and a refusal followed by three buffered `p`s is the same hazard as a success
826
+ followed by three."""
827
+ ok, status = apply_verdict(queue[cur] if cur < len(queue) else None, verdict, args)
828
+ drain_keys()
829
+ if not ok:
830
+ return state, cur, MODE_VERDICTS, status, False
831
+ state = _reload(state, args)
832
+ cur, mode = after_verdict(cur, len(verdict_queue(state)))
833
+ return state, cur, mode, status, True
834
+
835
+
342
836
  def _loop(state, args):
343
- sel = 0
837
+ sel = 0 # the board's cursor
838
+ vsel = 0 # the verdict queue's cursor, kept apart from it
839
+ mode = MODE_BOARD
840
+ status = ""
841
+ cooldown_until = 0.0
842
+ interval = max(MIN_REFRESH, float(args.refresh or 0))
843
+ paths = watch_paths(args)
844
+ _seed, seen = _changed(paths, {}) # the first frame is already current
845
+ dirty = True
344
846
  sys.stdout.write("\x1b[?25l")
345
847
  try:
346
848
  while True:
347
- frame = render(state, terminal_size(args.cols, args.rows), sel=sel, plain=False)
348
- sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(frame))
349
- sys.stdout.flush()
350
- sel, quit_now, reload_now = dispatch(
351
- read_key(), sel, len(state.get("obligations") or []))
352
- if quit_now:
353
- return 0
354
- if reload_now:
355
- state = load_state(args.state, args.ledger)
849
+ if dirty:
850
+ frame = render(state, terminal_size(args.cols, args.rows),
851
+ sel=(vsel if mode == MODE_VERDICTS else sel), plain=False,
852
+ mode=mode, status=status)
853
+ sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(frame))
854
+ sys.stdout.flush()
855
+ dirty = False
856
+ # one wait serves both jobs: a key answers immediately, and the deadline is the
857
+ # `--refresh` tick that re-reads only when a watched file actually moved.
858
+ key = wait_key(interval)
859
+ if key:
860
+ queue = verdict_queue(state)
861
+ if mode == MODE_VERDICTS:
862
+ cur, count = vsel, len(queue)
863
+ else:
864
+ cur, count = sel, len(state.get("obligations") or [])
865
+ # the last write's line lives exactly one frame: the next keypress clears it,
866
+ # so a stale confirmation never sits over a board that has moved on.
867
+ status = ""
868
+ cooling = time.time() < cooldown_until
869
+ new_mode, cur, quit_now, reload_now, verdict = dispatch_mode(
870
+ key, mode, cur, count, cooling=cooling)
871
+ if quit_now:
872
+ return 0
873
+ if verdict:
874
+ state, cur, new_mode, status, wrote = _apply_and_advance(
875
+ state, args, queue, cur, verdict)
876
+ cooldown_until = time.time() + VERDICT_COOLDOWN
877
+ if wrote:
878
+ _fresh, seen = _changed(paths, seen)
879
+ elif key in VERDICTS and cooling:
880
+ # the key WAS a verdict and it was refused: say why. A keypress that
881
+ # vanishes silently reads as a dropped input, and the next reflex is to
882
+ # press it again -- which is the repeat this cooldown exists to stop.
883
+ status = VERDICT_COOLDOWN_MSG
884
+ elif reload_now:
885
+ state = _reload(state, args)
886
+ _fresh, seen = _changed(paths, seen)
887
+ # each mode keeps its OWN cursor: coming back from a verdict must not move
888
+ # the row the reader left highlighted on the board.
889
+ if new_mode == MODE_VERDICTS:
890
+ vsel = cur
891
+ elif mode == MODE_BOARD:
892
+ sel = cur
893
+ mode = new_mode
894
+ dirty = True
895
+ continue
896
+ moved, seen = _changed(paths, seen)
897
+ if moved:
898
+ state = _reload(state, args)
899
+ dirty = True
356
900
  except KeyboardInterrupt:
357
901
  return 0
358
902
  finally:
@@ -374,7 +918,14 @@ def build_parser():
374
918
  parser.add_argument("--plain", action="store_true",
375
919
  help="never emit escape sequences")
376
920
  parser.add_argument("--refresh", type=float, default=2.0,
377
- help="reserved for the M2 timed poll; M1 re-reads on the `r` key")
921
+ help="seconds between mtime polls of the ledger (default: 2, "
922
+ "floor %.1f); `r` still forces a re-read" % MIN_REFRESH)
923
+ parser.add_argument("--human", default=None,
924
+ help="who is at the keyboard: the name recorded on every verdict "
925
+ "this session writes (default: $USERNAME/$USER; with neither "
926
+ "set, `curate`'s own default applies). The person pressing the "
927
+ "key is the author of the judgement -- the TUI never invents a "
928
+ "name for it")
378
929
  parser.add_argument("--cols", type=int, default=None)
379
930
  parser.add_argument("--rows", type=int, default=None)
380
931
  return parser
@@ -387,6 +938,7 @@ def main(argv=None):
387
938
  except Exception:
388
939
  pass
389
940
  args = build_parser().parse_args(argv)
941
+ args.human = resolve_human(args.human)
390
942
  try:
391
943
  state = load_state(args.state, args.ledger)
392
944
  except (OSError, ValueError, RuntimeError) as exc: