@andresmassello/uscha 1.88.0 → 1.90.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.
Files changed (36) hide show
  1. package/README.md +11 -7
  2. package/package.json +1 -1
  3. package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +211 -38
  4. package/uscha-kit/.claude/skills/uscha-devloop/uscha_top.py +501 -24
  5. package/uscha-kit/.claude-plugin/plugin.json +1 -1
  6. package/uscha-kit/.codex-plugin/plugin.json +1 -1
  7. package/uscha-kit/README.md +1 -1
  8. package/uscha-kit/VERSION +1 -1
  9. package/uscha-kit/install-uscha.py +8 -1
  10. package/uscha-kit/skills/uscha-devloop/qa_ledger.py +211 -38
  11. package/uscha-kit/skills/uscha-devloop/uscha_top.py +501 -24
  12. package/uscha-kit/uscha.config.json +1 -1
  13. package/uscha-kit/reports/junit/.bench-cases.json +0 -1
  14. package/uscha-kit/reports/junit/.bench-curate-cases.json +0 -1
  15. package/uscha-kit/reports/junit/.bootstrap-cases.json +0 -1
  16. package/uscha-kit/reports/junit/.cleanroom-cases.json +0 -1
  17. package/uscha-kit/reports/junit/.compile-cases.json +0 -1
  18. package/uscha-kit/reports/junit/.curation-cases.json +0 -1
  19. package/uscha-kit/reports/junit/.delta-cases.json +0 -1
  20. package/uscha-kit/reports/junit/.fa-cases.json +0 -1
  21. package/uscha-kit/reports/junit/.facts-cases.json +0 -1
  22. package/uscha-kit/reports/junit/.fastpath-cases.json +0 -1
  23. package/uscha-kit/reports/junit/.fidelity-cases.json +0 -1
  24. package/uscha-kit/reports/junit/.goldencov-cases.json +0 -1
  25. package/uscha-kit/reports/junit/.ir-cases.json +0 -1
  26. package/uscha-kit/reports/junit/.js-cases.json +0 -1
  27. package/uscha-kit/reports/junit/.lang-cases.json +0 -1
  28. package/uscha-kit/reports/junit/.lang3-cases.json +0 -1
  29. package/uscha-kit/reports/junit/.multi-cases.json +0 -1
  30. package/uscha-kit/reports/junit/.oracle-cases.json +0 -1
  31. package/uscha-kit/reports/junit/.origin-cases.json +0 -1
  32. package/uscha-kit/reports/junit/.r2-cases.json +0 -1
  33. package/uscha-kit/reports/junit/.rt-cases.json +0 -1
  34. package/uscha-kit/reports/junit/.sched-cases.json +0 -1
  35. package/uscha-kit/reports/junit/.specdrift-cases.json +0 -1
  36. package/uscha-kit/reports/junit/.top-cases.json +0 -1
@@ -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
- M2 scope: the read-only BOARD plus the live feed and its mtime poll. VERDICTS mode (M3) is
17
- not wired; the pane that will hold it is 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
  """
@@ -26,6 +30,7 @@ import shutil
26
30
  import subprocess
27
31
  import sys
28
32
  import time
33
+ import unicodedata
29
34
 
30
35
  DEFAULT_LEDGER = "QA-LEDGER.json"
31
36
  FALLBACK_SIZE = (100, 32)
@@ -37,6 +42,26 @@ FEED_MAX = 8 # = the engine's events_tail length; a short terminal sh
37
42
  BURNUP_MAX = 24
38
43
  MIN_REFRESH = 0.5 # a poll faster than this is a busy loop, not a refresh
39
44
 
45
+ MODE_BOARD = "board"
46
+ MODE_VERDICTS = "verdicts"
47
+ # VERDICTS geometry: title, rule, the pending line, the rule under the list, the rule under
48
+ # the pane, the status line, the key hint. Everything else is queue rows + the detail pane.
49
+ VERDICT_CHROME = 7
50
+ VERDICT_LIST_MAX = 9 # [1]..[9]: exactly the observations a single keypress can select
51
+ SIDE_BY_SIDE_MIN = 100 # narrower than this, candidate and evidence stack instead of pairing
52
+ # The three verdicts `curate` accepts, and nothing else: the vocabulary belongs to ADR-013.
53
+ VERDICTS = {"p": "preserve", "f": "fix", "u": "undefined"}
54
+ CURATE_NOTE = "recorded via uscha top"
55
+ VERDICT_HINT = "the only write is a verdict, recorded by `qa_ledger.py curate`"
56
+ # A held key repeats. Because the queue ADVANCES after every write, repeat number two would
57
+ # land on an observation the human never read -- N verdicts from one glance, which is the
58
+ # batch INV-CURATION-01 forbids arriving one legitimate call at a time. Two guards: the input
59
+ # buffer is drained after a write (`drain_keys`), and for this long a verdict key is refused
60
+ # outright, saying so instead of swallowing it.
61
+ VERDICT_COOLDOWN = 0.25
62
+ VERDICT_COOLDOWN_MSG = ("verdict recorded -- release the key (the queue advanced; the next "
63
+ "observation is a new judgement)")
64
+
40
65
  # ANSI SGR by obligation state. TRACED and TAGGED deliberately share the UNMEASURED gray:
41
66
  # the v0.1 engine has no source for either rung (ADR-032), so they must read as "not
42
67
  # measured", never as PASS (INV-TOP-02, AC-T-08).
@@ -81,21 +106,71 @@ ACTIONS = {
81
106
  # --------------------------------------------------------------------------- #
82
107
  # pure rendering #
83
108
  # --------------------------------------------------------------------------- #
109
+ def _dw(text):
110
+ """Display width in TERMINAL COLUMNS, not codepoints.
111
+
112
+ `len()` counts codepoints, and the frame's whole contract is columns: a CJK project name
113
+ or a full-width event text takes two columns per codepoint, so a line `len()` called
114
+ exactly `cols` wide draws twice that and the frame every golden pins stops being a frame
115
+ (1.86.1 fresh review, LOW, deferred until a fixture existed -- `state-wide.json` is it).
116
+
117
+ Three classes, and no font metric anywhere: East Asian Wide and Fullwidth cost 2, a
118
+ combining mark costs 0 (it draws on the previous cell), everything else costs 1. East
119
+ Asian *Ambiguous* deliberately counts 1 -- that is the class the renderer's own glyphs
120
+ fall into (`…`, `·`, `─`, `│`, `▁`, `—`), so on an ASCII state `_dw` is `len` and every
121
+ frame captured before this function existed stays byte-identical."""
122
+ width = 0
123
+ for ch in str(text):
124
+ if unicodedata.combining(ch):
125
+ continue
126
+ width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
127
+ return width
128
+
129
+
130
+ def _cut(text, width):
131
+ """The longest PREFIX of `text` that fits in `width` columns, whole characters only.
132
+
133
+ A wide character is never split: half a glyph is not half a column, it is a cell the
134
+ terminal fills however it likes and a frame nobody can snapshot."""
135
+ if width <= 0:
136
+ return ""
137
+ out, used = [], 0
138
+ for ch in str(text):
139
+ w = 0 if unicodedata.combining(ch) else (
140
+ 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1)
141
+ if used + w > width:
142
+ break
143
+ out.append(ch)
144
+ used += w
145
+ return "".join(out)
146
+
147
+
148
+ def _pad(text, width):
149
+ """`str.ljust` measured in columns. Padding by codepoints puts a wide cell's second
150
+ column inside the next field and every column after it walks."""
151
+ return str(text) + " " * max(0, width - _dw(text))
152
+
153
+
84
154
  def _fit(text, cols):
85
155
  """One line, never wider than the terminal. Wrapping would break the frame's row
86
- accounting, so an over-long line is cut and marked."""
156
+ accounting, so an over-long line is cut and marked.
157
+
158
+ Measured and cut in COLUMNS (`_dw`/`_cut`): cutting by codepoints was the bug. Note the
159
+ cut may leave one column short rather than land exactly on `cols - 1` -- when the
160
+ character at the boundary is wide it is dropped whole, and a frame one column narrow is
161
+ correct where a frame one column wide is not."""
87
162
  if cols <= 0:
88
163
  return ""
89
- if len(text) <= cols:
164
+ if _dw(text) <= cols:
90
165
  return text
91
- return text[:cols - 1] + "…" if cols > 1 else text[:cols]
166
+ return _cut(text, cols - 1) + "…" if cols > 1 else _cut(text, cols)
92
167
 
93
168
 
94
169
  def _spread(left, right, cols):
95
170
  """left ... right on one line, right-aligned, degrading to just `left` when tight."""
96
- if len(left) + len(right) + 1 > cols:
171
+ if _dw(left) + _dw(right) + 1 > cols:
97
172
  return _fit(left, cols)
98
- return left + " " * (cols - len(left) - len(right)) + right
173
+ return left + " " * (cols - _dw(left) - _dw(right)) + right
99
174
 
100
175
 
101
176
  def _num(value):
@@ -127,7 +202,7 @@ def _burnup_line(burnup, cols):
127
202
  note = " (readiness score, not closed obligations)"
128
203
  if not points:
129
204
  return label + DASH + " (no `readiness --record` history yet)"
130
- room = max(4, min(BURNUP_MAX, cols - len(label) - len(note)))
205
+ room = max(4, min(BURNUP_MAX, cols - _dw(label) - _dw(note)))
131
206
  bars = "".join(BLOCKS[min(len(BLOCKS) - 1, max(0, int(p) * len(BLOCKS) // 101))]
132
207
  for p in points[-room:])
133
208
  return label + bars + note
@@ -156,10 +231,13 @@ def _cases_text(ob):
156
231
 
157
232
 
158
233
  def _row(ob, selected):
234
+ # the three left columns are cut and padded in COLUMNS: an id or state carrying wide
235
+ # characters used to eat its neighbour's field and walk every column after it.
159
236
  gutter = "> " if selected else " "
160
- return "%s%-8s%-9s%-15s%7s%5s %s" % (
161
- gutter, _safe(ob.get("id") or "?")[:8], _safe(ob.get("gate") or DASH)[:8],
162
- _safe(ob.get("state") or "?")[:14], _cases_text(ob),
237
+ return "%s%s%s%s%7s%5s %s" % (
238
+ gutter, _pad(_cut(_safe(ob.get("id") or "?"), 8), 8),
239
+ _pad(_cut(_safe(ob.get("gate") or DASH), 8), 9),
240
+ _pad(_cut(_safe(ob.get("state") or "?"), 14), 15), _cases_text(ob),
163
241
  _num(ob.get("age_hours")), ACTIONS.get(ob.get("state"), DASH))
164
242
 
165
243
 
@@ -167,8 +245,22 @@ def _safe(text):
167
245
  """No control character reaches the terminal through the feed. The engine already
168
246
  strips them where the text is derived (`_top_event_text`); this is the second guard on
169
247
  the same surface, because the renderer also accepts a frozen state file a human wrote,
170
- and one ESC in it would be a control sequence the board obeys instead of prints."""
171
- return "".join(c for c in str(text or "") if ord(c) >= 32 and ord(c) != 127)
248
+ and one ESC in it would be a control sequence the board obeys instead of prints.
249
+
250
+ Dropped (1.90.0), matching the engine's `_top_clean` exactly: C0 and DEL, the C1 range
251
+ U+0080-U+009F (a terminal reading the stream as latin-1 takes those for CSI/OSC), and
252
+ every Unicode format character (category `Cf`) -- U+200B costs a codepoint and no column,
253
+ U+202E reverses everything after it. A character that cannot be seen must not be able to
254
+ move what is."""
255
+ out = []
256
+ for ch in str(text or ""):
257
+ code = ord(ch)
258
+ if code < 32 or code == 127 or 0x80 <= code <= 0x9F:
259
+ continue
260
+ if unicodedata.category(ch) == "Cf":
261
+ continue
262
+ out.append(ch)
263
+ return "".join(out)
172
264
 
173
265
 
174
266
  def _feed_line(ev, cols, plain):
@@ -189,14 +281,23 @@ def _colorize(line, state):
189
281
  return line.replace(state, "\x1b[%sm%s%s" % (code, state, RESET), 1)
190
282
 
191
283
 
192
- def render(state, size, sel=0, plain=True):
193
- """The whole board as a list of exactly `rows` lines, none wider than `cols`.
284
+ def render(state, size, sel=0, plain=True, mode=MODE_BOARD, status=""):
285
+ """One frame: exactly `rows` lines, none wider than `cols`.
194
286
 
195
287
  PURE (ADR-034): no I/O, no clock, no randomness, no environment. `state` is the parsed
196
- `qa_ledger.py top --json` object; `size` is (cols, rows); `sel` is the highlighted row.
197
- plain=True emits no escape sequences at all -- the mode `--once`, CI and the golden
198
- frames use, so a snapshot compares text and not terminal control codes.
288
+ `qa_ledger.py top --json` object; `size` is (cols, rows); `sel` is the highlighted row of
289
+ the ACTIVE mode; `mode` picks the board or the verdicts queue; `status` is the last line
290
+ the write path produced (a parameter, not a global, so the frame stays a pure function of
291
+ its inputs and the golden frames stay reproducible). plain=True emits no escape sequences
292
+ at all -- the mode `--once`, CI and the golden frames use, so a snapshot compares text and
293
+ not terminal control codes.
199
294
  """
295
+ if mode == MODE_VERDICTS:
296
+ return _render_verdicts(state, size, sel, plain, status)
297
+ return _render_board(state, size, sel, plain, status)
298
+
299
+
300
+ def _render_board(state, size, sel, plain, status=""):
200
301
  cols, rows = size
201
302
  cols = max(20, int(cols))
202
303
  rows = max(CHROME_LINES + 1, int(rows))
@@ -276,9 +377,19 @@ def render(state, size, sel=0, plain=True):
276
377
  # sent: a feed that silently drops lines is a feed that can hide the red one.
277
378
  out.append("feed %s %d/%d %s newest first %s P/F/H/U/I = pass/fail/human/"
278
379
  "unmeasured/info" % (MID, len(shown), len(events), MID, MID))
380
+ if status:
381
+ # the LAST verdict of a queue empties it and drops the reader back here, so the
382
+ # engine's own confirmation would otherwise vanish with the mode that showed it. It
383
+ # takes the feed's label line for exactly one frame (the next keypress clears it) --
384
+ # the feed's own `N/M` count returns with it. `status` is empty on every other path,
385
+ # which is why the golden frames never see this line.
386
+ out[-1] = _fit("status %s %s" % (MID, _safe(status)), cols)
279
387
  for i in range(feed_n):
280
388
  out.append(_feed_line(shown[i], cols, plain) if i < len(shown) else "")
281
- out.append("[j/k] move %s [r] reload %s [q] quit %s [v] verdicts (M3) %s "
389
+ # `[v] verdicts` lost its `(M3)` marker in 1.89.0 because the key now works; `[d]/[o]`
390
+ # keeps its `phase 2` marker because those two still do nothing (SPEC s1/s6). A hint that
391
+ # labels a live key as future is the same class of stale claim the frames exist to catch.
392
+ out.append("[j/k] move %s [r] reload %s [q] quit %s [v] verdicts %s "
282
393
  "[d]/[o] phase 2" % (MID, MID, MID, MID))
283
394
  # a coloured line was already fitted BEFORE its escape bytes went in (table rows and
284
395
  # feed lines both), and re-fitting it here would count those bytes as visible width --
@@ -290,6 +401,157 @@ def render(state, size, sel=0, plain=True):
290
401
  return out
291
402
 
292
403
 
404
+ # --------------------------------------------------------------------------- #
405
+ # VERDICTS mode -- the queue, the detail pane, and the keymap that writes #
406
+ # --------------------------------------------------------------------------- #
407
+ def verdict_queue(state):
408
+ """The pending queue is EXACTLY what the engine emitted. `observations[]` already holds
409
+ only uncurated observations, in the order `cmd_top` fixed (the anchored criterion first,
410
+ then the id). The TUI filters nothing and sorts nothing -- a second place that decides
411
+ what is pending is a second place that can disagree with the ledger (ADR-032)."""
412
+ return [o for o in (state.get("observations") or []) if isinstance(o, dict)]
413
+
414
+
415
+ def _wrap(text, width):
416
+ """Whole words onto as many lines as they need.
417
+
418
+ The pane must never cut a claim in half: a claim the reader cannot finish is a verdict
419
+ recorded on half the evidence (AC-T-14). A single token wider than the pane is hard-split
420
+ and CONTINUES on the next line, so nothing is dropped either way."""
421
+ width = max(8, int(width))
422
+ out, line = [], ""
423
+ for word in _safe(text).split():
424
+ if not line:
425
+ line = word
426
+ elif _dw(line) + 1 + _dw(word) <= width:
427
+ line += " " + word
428
+ else:
429
+ out.append(line)
430
+ line = word
431
+ # the hard split is measured in columns too, and `_cut` never breaks a wide
432
+ # character in half -- so a wide token continues on the next line, whole.
433
+ while _dw(line) > width:
434
+ head = _cut(line, width)
435
+ out.append(head)
436
+ line = line[len(head):]
437
+ if line:
438
+ out.append(line)
439
+ return out or [""]
440
+
441
+
442
+ def _obs_row(i, ob, selected, cols):
443
+ """One queue line: `[n] OBS-id title · AC-x · pending`. The TITLE is the engine's
444
+ capped head of the claim; the whole claim lives in the pane below, never here."""
445
+ gutter = "> " if selected else " "
446
+ idx = "[%d]" % (i + 1) if i < VERDICT_LIST_MAX else " "
447
+ tail = " %s %s %s pending" % (MID, _safe(ob.get("ac")) or ("AC " + DASH), MID)
448
+ head = "%s%-4s%s" % (gutter, idx, _pad(_cut(_safe(ob.get("id")), 18), 18))
449
+ room = max(4, cols - _dw(head) - _dw(tail))
450
+ title = _fit(_safe(ob.get("title")) or DASH, room)
451
+ return head + _pad(title, room) + tail
452
+
453
+
454
+ def _column_widths(cols):
455
+ """` <left> │ <right>` spends 2 on the gutter and 3 on the divider."""
456
+ left = (cols - 5) // 2
457
+ return left, cols - 5 - left
458
+
459
+
460
+ def _pane(ob, cols, height):
461
+ """The detail of the selected observation, in exactly `height` lines.
462
+
463
+ Side by side while there is room, stacked below `SIDE_BY_SIDE_MIN` columns -- a
464
+ 40-character column is not a pane, it is a word per line. Content that still does not fit
465
+ is NOT silently cut: the last line says how many lines are missing, which is the same
466
+ discipline the feed's `5/7` label follows."""
467
+ if height <= 0:
468
+ return []
469
+ if not ob:
470
+ body = [" no observation selected %s the queue is empty ([t] returns to the board)"
471
+ % MID]
472
+ else:
473
+ head = [" %s %s %s %s repo %s" % (_safe(ob.get("id")) or "?", MID,
474
+ _safe(ob.get("ac")) or ("AC " + DASH), MID,
475
+ _safe(ob.get("repo")) or DASH), ""]
476
+ def block(key, width):
477
+ return [ln for x in (ob.get(key) or []) for ln in _wrap(x, width)]
478
+
479
+ if cols >= SIDE_BY_SIDE_MIN:
480
+ lw, rw = _column_widths(cols)
481
+ left = ["CANDIDATE"] + block("candidate", lw)
482
+ right = ["EVIDENCE"] + block("evidence", rw)
483
+ pad = max(len(left), len(right))
484
+ left += [""] * (pad - len(left))
485
+ right += [""] * (pad - len(right))
486
+ body = head + [(" %s │ %s" % (_pad(l, lw), r)).rstrip()
487
+ for l, r in zip(left, right)]
488
+ else:
489
+ body = (head + [" CANDIDATE"] + [" " + ln for ln in block("candidate", cols - 2)]
490
+ + [""] + [" EVIDENCE"] + [" " + ln for ln in block("evidence", cols - 2)])
491
+ if len(body) > height:
492
+ body = body[:height - 1] + [" %s %d more line(s) of this observation do not fit at "
493
+ "this size" % (DASH, len(body) - (height - 1))]
494
+ return [_fit(ln, cols) for ln in body] + [""] * max(0, height - len(body))
495
+
496
+
497
+ def _render_verdicts(state, size, sel, plain, status):
498
+ """The verdicts queue. Read-only like every other frame -- the write happens in the
499
+ dispatch, never in the renderer (ADR-034: `render` performs no I/O at all).
500
+
501
+ `plain` is accepted and not used: this frame carries no colour of its own (the `>` gutter
502
+ marks the selection, and a state colour here would decorate a claim rather than a
503
+ verdict), so the coloured and plain paths are the same lines. Keeping the parameter keeps
504
+ one render signature, and keeps the golden frames comparing the frame the terminal draws."""
505
+ cols, rows = size
506
+ cols = max(20, int(cols))
507
+ rows = max(VERDICT_CHROME + 4, int(rows))
508
+ queue = verdict_queue(state)
509
+ debtors = state.get("debtors") or {}
510
+ sel = max(0, min(int(sel), max(0, len(queue) - 1)))
511
+
512
+ out = [_spread("uscha top %s %s %s verdicts"
513
+ % (MID, _safe(state.get("project")) or "(unnamed project)", MID),
514
+ "step #%s" % _safe(_num(state.get("step"))), cols),
515
+ RULE * cols,
516
+ # the two numbers are DIFFERENT facts and both are named: `pending` counts
517
+ # uncurated observations, `you owe` counts the criteria they hold in quarantine.
518
+ # One observation can name no criterion at all, so conflating them would inflate
519
+ # whichever is shown alone.
520
+ _fit("pending %d %s you owe %s %s a verdict never moves DONE (INV-TOP-03)"
521
+ % (len(queue), MID, _num(debtors.get("you")), MID), cols)]
522
+
523
+ avail = rows - VERDICT_CHROME
524
+ want = len(queue) or 1
525
+ list_n = max(1, min(VERDICT_LIST_MAX, want, avail - 3))
526
+ body = list_n - 1 if len(queue) > list_n else list_n
527
+ body = max(1, body)
528
+ top = 0
529
+ if sel >= body:
530
+ top = min(sel - body + 1, max(0, len(queue) - body))
531
+ rowsout = [_fit(_obs_row(i, ob, i == sel, cols), cols)
532
+ for i, ob in enumerate(queue[top:top + body], start=top)]
533
+ if len(queue) > len(rowsout):
534
+ rowsout.append(_fit(" %s %d more observation(s) not shown (j/k to move)"
535
+ % (DASH, len(queue) - len(rowsout)), cols))
536
+ if not queue:
537
+ rowsout = [_fit(" nothing uncurated %s every observation carries a verdict "
538
+ "(`promote` is a human step, not this one)" % MID, cols)]
539
+ out.extend(rowsout[:list_n])
540
+ out.extend([""] * max(0, list_n - len(rowsout)))
541
+
542
+ out.append(RULE * cols)
543
+ out.extend(_pane(queue[sel] if queue else None, cols, avail - list_n))
544
+ out.append(RULE * cols)
545
+ out.append(_fit("status %s %s" % (MID, _safe(status) or VERDICT_HINT), cols))
546
+ # every key this mode answers to is on the line, `[r]` included: the queue is re-read from
547
+ # a ledger another process can move, and a reload the reader cannot find is a reload that
548
+ # does not exist. Abbreviated to fit the 80-column floor without the `…` cut.
549
+ out.append(_fit("[jk/1-9] move %s [p]reserve %s [f]ix %s [u]ndefined %s [r]eload %s "
550
+ "[t] back %s [q]uit" % (MID, MID, MID, MID, MID, MID), cols))
551
+ out = [line if "\x1b" in line else _fit(line, cols) for line in out]
552
+ return out[:rows] + [""] * max(0, rows - len(out))
553
+
554
+
293
555
  # --------------------------------------------------------------------------- #
294
556
  # state loading (the ONE read boundary -- it shells out, it never re-derives) #
295
557
  # --------------------------------------------------------------------------- #
@@ -403,6 +665,50 @@ def wait_key(timeout):
403
665
  termios.tcsetattr(fd, termios.TCSADRAIN, saved)
404
666
 
405
667
 
668
+ DRAIN_MAX = 256 # a terminal that never stops reporting input is not drained forever
669
+
670
+
671
+ def drain_keys():
672
+ """Throw away whatever is ALREADY in the input buffer, and say how much it threw.
673
+
674
+ Called right after a verdict. `wait_key` reads one byte per turn of the loop, so a held
675
+ key (or a fast repeat, or a paste) leaves N keypresses queued -- and because the queue
676
+ advances after every write, keypress two would judge the observation that just moved into
677
+ the cursor's place. Draining is what makes "one keypress, one verdict" true of the
678
+ KEYBOARD and not only of the dispatch (ADR-033, INV-CURATION-01).
679
+
680
+ Same family as `read_key`/`wait_key` and isolated for the same reason: the driver is not
681
+ what the suite tests, so it must be replaceable. Without a terminal it drops nothing and
682
+ returns 0 rather than raising -- a pipe has no held key to drain."""
683
+ dropped = 0
684
+ if os.name == "nt":
685
+ try:
686
+ import msvcrt
687
+ while dropped < DRAIN_MAX and msvcrt.kbhit():
688
+ msvcrt.getch()
689
+ dropped += 1
690
+ except Exception:
691
+ return dropped # no console: nothing was buffered
692
+ return dropped
693
+ import select
694
+ import termios
695
+ import tty
696
+ fd = sys.stdin.fileno()
697
+ try:
698
+ saved = termios.tcgetattr(fd)
699
+ except Exception:
700
+ return 0 # no terminal: nothing to drain
701
+ try:
702
+ tty.setraw(fd)
703
+ while dropped < DRAIN_MAX and select.select([sys.stdin], [], [], 0)[0]:
704
+ if not sys.stdin.read(1):
705
+ break # EOF reads ready forever: stop
706
+ dropped += 1
707
+ finally:
708
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
709
+ return dropped
710
+
711
+
406
712
  def _changed(paths, seen):
407
713
  """(changed?, new snapshot) for a set of files, by (mtime, size).
408
714
 
@@ -430,6 +736,117 @@ def watch_paths(args):
430
736
  return [args.state] if getattr(args, "state", None) else [getattr(args, "ledger", None)]
431
737
 
432
738
 
739
+ def resolve_human(explicit=None):
740
+ """Who is at the keyboard. The person recording the verdict is its author, so the TUI
741
+ passes the name EXPLICITLY (ADR-033) instead of letting the engine guess in a different
742
+ process -- an SSH or multi-user session would otherwise attribute the judgement to
743
+ whoever owns the environment. It never invents one: with nothing to resolve this returns
744
+ None, `--human` is left off the call, and `curate`'s own default stands."""
745
+ return explicit or os.environ.get("USERNAME") or os.environ.get("USER") or None
746
+
747
+
748
+ def _curate_call(engine, ledger, repo, obs_id, verdict, human=None, note=CURATE_NOTE):
749
+ """THE single write of this application (ADR-033): one process, one observation, one
750
+ verdict. The TUI never opens the ledger for writing and never constructs a curation
751
+ record -- the record shape belongs to `curate` (ADR-013), which is exactly what the
752
+ byte-equal fixture (AC-T-17) measures.
753
+
754
+ It is one function on purpose: it is the boundary the suite replaces to assert the argv
755
+ and the ONE call per keypress without writing anything (AC-T-15).
756
+
757
+ Returns (returncode, the engine's own last line)."""
758
+ argv = [sys.executable, engine, "curate", "--ledger", ledger, "--repo", repo,
759
+ "--obs", obs_id, "--verdict", verdict]
760
+ if human:
761
+ argv += ["--human", human]
762
+ argv += ["--note", note]
763
+ proc = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
764
+ said = ((proc.stderr or b"").decode("utf-8", "replace").strip().splitlines()
765
+ or (proc.stdout or b"").decode("utf-8", "replace").strip().splitlines())
766
+ return proc.returncode, (said[-1] if said else "")
767
+
768
+
769
+ def apply_verdict(ob, verdict, args, engine=None):
770
+ """One keypress -> one `curate` call, synchronously, for the ONE selected observation.
771
+
772
+ A refusal by the engine (an unknown OBS, a malformed delta, a batch-looking id) comes
773
+ back as the engine's OWN line and is surfaced: the selection does not advance and nothing
774
+ is retried. A retry loop over a refusal is how a batch gets written one call at a time,
775
+ which is the thing INV-CURATION-01 exists to make impossible.
776
+
777
+ Returns (recorded?, the line the status bar shows)."""
778
+ if getattr(args, "state", None):
779
+ # `--state` renders a FROZEN snapshot: the ledger on disk is not the one on screen (it
780
+ # may be another project's, or none at all). A verdict recorded from it would judge an
781
+ # observation the reader is not looking at -- refused, and named.
782
+ return False, "--state is a frozen snapshot -- verdicts need a live ledger"
783
+ if not ob or not ob.get("id"):
784
+ return False, "no observation selected: nothing to record"
785
+ if not ob.get("repo"):
786
+ return False, ("%s carries no repo in `top --json` -- curate needs one (--repo)"
787
+ % ob.get("id"))
788
+ eng = engine or engine_path()
789
+ if not eng:
790
+ return False, "qa_ledger.py not found next to uscha_top.py"
791
+ rc, said = _curate_call(eng, args.ledger, ob["repo"], ob["id"], verdict,
792
+ getattr(args, "human", None))
793
+ if rc == 0:
794
+ return True, said or ("%s = %s recorded" % (ob["id"], verdict))
795
+ return False, said or ("curate exited %s -- nothing was recorded" % rc)
796
+
797
+
798
+ def after_verdict(sel, count):
799
+ """Where the cursor lands once the queue has been re-read: (selection, mode).
800
+
801
+ The observation just judged is GONE from `observations[]` (the engine emits only
802
+ uncurated ones), so the next pending observation has taken its index -- the selection
803
+ stays put and only clamps at the end. An empty queue is the signal to go back to the
804
+ board: there is nothing left to judge, and a verdicts pane over an empty queue invites a
805
+ second verdict on nothing."""
806
+ if count <= 0:
807
+ return 0, MODE_BOARD
808
+ return max(0, min(sel, count - 1)), MODE_VERDICTS
809
+
810
+
811
+ def dispatch_mode(key, mode, sel, count, cooling=False):
812
+ """The mode machine: key + current mode -> (mode, selection, quit?, reload?, verdict).
813
+
814
+ Pure, and the ONE place a keypress becomes a write decision -- `verdict` is a string the
815
+ caller then spends on exactly one `curate` call, never a loop. The BOARD keymap is
816
+ `dispatch` below, unchanged and still measured on its own, so nothing about the board's
817
+ keys moved when this was layered on top. `sel` belongs to the ACTIVE mode; a mode change
818
+ hands back 0 and the caller keeps the other mode's cursor.
819
+
820
+ `cooling` is the caller's answer to "is a verdict still echoing?" (it owns the clock; this
821
+ stays pure). While it is true, `p`/`f`/`u` produce NO verdict: a key held down repeats,
822
+ and the second repeat would judge the observation that just took the cursor's place. Every
823
+ other key keeps working -- the cooldown blocks writes, not the reader."""
824
+ if mode != MODE_VERDICTS:
825
+ if key in ("v", "V"):
826
+ return MODE_VERDICTS, 0, False, False, None
827
+ sel, quit_now, reload_now = dispatch(key, sel, count)
828
+ return MODE_BOARD, sel, quit_now, reload_now, None
829
+ if key in ("q", "Q", "\x03"):
830
+ return mode, sel, True, False, None
831
+ if key in ("t", "T", "\x1b"):
832
+ return MODE_BOARD, 0, False, False, None
833
+ if key == "j":
834
+ return mode, min(sel + 1, max(0, count - 1)), False, False, None
835
+ if key == "k":
836
+ return mode, max(0, sel - 1), False, False, None
837
+ if key == "r":
838
+ return mode, sel, False, True, None
839
+ if key in VERDICTS:
840
+ # an empty queue produces NO verdict: there is nothing selected to judge, and a
841
+ # keypress that writes anyway would be a verdict the human never aimed at an OBS.
842
+ # Neither does a queue still cooling from the last one.
843
+ return mode, sel, False, False, (VERDICTS[key] if (count and not cooling) else None)
844
+ if len(str(key)) == 1 and key in "123456789":
845
+ n = int(key) - 1
846
+ return mode, (n if n < count else sel), False, False, None
847
+ return mode, sel, False, False, None
848
+
849
+
433
850
  def dispatch(key, sel, count):
434
851
  """Key -> (new selection, quit?, reload?). Pure, so the keymap is testable without a
435
852
  terminal: the driver below is not what is under test, this dispatch is (ADR-034)."""
@@ -466,8 +883,33 @@ def _reload(state, args):
466
883
  return state
467
884
 
468
885
 
886
+ def _apply_and_advance(state, args, queue, cur, verdict):
887
+ """ONE keypress -> ONE curate process -> re-read. Returns (state, sel, mode, status, wrote?).
888
+
889
+ This lives in a function of its own, and not as four lines inside the key loop, for a
890
+ reason the suite asserts structurally (AC-T-15): the module's single call to
891
+ `apply_verdict` must have no `for` or `while` above it, so no later edit can quietly turn
892
+ one keypress into a pass over the queue. The re-read afterwards is a READ -- the verdict
893
+ left the queue and the board behind it did not move (INV-TOP-03); nothing reruns.
894
+
895
+ The input buffer is drained whether the write landed or not: a held key queues repeats
896
+ either way, and a refusal followed by three buffered `p`s is the same hazard as a success
897
+ followed by three."""
898
+ ok, status = apply_verdict(queue[cur] if cur < len(queue) else None, verdict, args)
899
+ drain_keys()
900
+ if not ok:
901
+ return state, cur, MODE_VERDICTS, status, False
902
+ state = _reload(state, args)
903
+ cur, mode = after_verdict(cur, len(verdict_queue(state)))
904
+ return state, cur, mode, status, True
905
+
906
+
469
907
  def _loop(state, args):
470
- sel = 0
908
+ sel = 0 # the board's cursor
909
+ vsel = 0 # the verdict queue's cursor, kept apart from it
910
+ mode = MODE_BOARD
911
+ status = ""
912
+ cooldown_until = 0.0
471
913
  interval = max(MIN_REFRESH, float(args.refresh or 0))
472
914
  paths = watch_paths(args)
473
915
  _seed, seen = _changed(paths, {}) # the first frame is already current
@@ -477,7 +919,8 @@ def _loop(state, args):
477
919
  while True:
478
920
  if dirty:
479
921
  frame = render(state, terminal_size(args.cols, args.rows),
480
- sel=sel, plain=False)
922
+ sel=(vsel if mode == MODE_VERDICTS else sel), plain=False,
923
+ mode=mode, status=status)
481
924
  sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(frame))
482
925
  sys.stdout.flush()
483
926
  dirty = False
@@ -485,13 +928,40 @@ def _loop(state, args):
485
928
  # `--refresh` tick that re-reads only when a watched file actually moved.
486
929
  key = wait_key(interval)
487
930
  if key:
488
- sel, quit_now, reload_now = dispatch(
489
- key, sel, len(state.get("obligations") or []))
931
+ queue = verdict_queue(state)
932
+ if mode == MODE_VERDICTS:
933
+ cur, count = vsel, len(queue)
934
+ else:
935
+ cur, count = sel, len(state.get("obligations") or [])
936
+ # the last write's line lives exactly one frame: the next keypress clears it,
937
+ # so a stale confirmation never sits over a board that has moved on.
938
+ status = ""
939
+ cooling = time.time() < cooldown_until
940
+ new_mode, cur, quit_now, reload_now, verdict = dispatch_mode(
941
+ key, mode, cur, count, cooling=cooling)
490
942
  if quit_now:
491
943
  return 0
492
- if reload_now:
944
+ if verdict:
945
+ state, cur, new_mode, status, wrote = _apply_and_advance(
946
+ state, args, queue, cur, verdict)
947
+ cooldown_until = time.time() + VERDICT_COOLDOWN
948
+ if wrote:
949
+ _fresh, seen = _changed(paths, seen)
950
+ elif key in VERDICTS and cooling:
951
+ # the key WAS a verdict and it was refused: say why. A keypress that
952
+ # vanishes silently reads as a dropped input, and the next reflex is to
953
+ # press it again -- which is the repeat this cooldown exists to stop.
954
+ status = VERDICT_COOLDOWN_MSG
955
+ elif reload_now:
493
956
  state = _reload(state, args)
494
957
  _fresh, seen = _changed(paths, seen)
958
+ # each mode keeps its OWN cursor: coming back from a verdict must not move
959
+ # the row the reader left highlighted on the board.
960
+ if new_mode == MODE_VERDICTS:
961
+ vsel = cur
962
+ elif mode == MODE_BOARD:
963
+ sel = cur
964
+ mode = new_mode
495
965
  dirty = True
496
966
  continue
497
967
  moved, seen = _changed(paths, seen)
@@ -521,6 +991,12 @@ def build_parser():
521
991
  parser.add_argument("--refresh", type=float, default=2.0,
522
992
  help="seconds between mtime polls of the ledger (default: 2, "
523
993
  "floor %.1f); `r` still forces a re-read" % MIN_REFRESH)
994
+ parser.add_argument("--human", default=None,
995
+ help="who is at the keyboard: the name recorded on every verdict "
996
+ "this session writes (default: $USERNAME/$USER; with neither "
997
+ "set, `curate`'s own default applies). The person pressing the "
998
+ "key is the author of the judgement -- the TUI never invents a "
999
+ "name for it")
524
1000
  parser.add_argument("--cols", type=int, default=None)
525
1001
  parser.add_argument("--rows", type=int, default=None)
526
1002
  return parser
@@ -533,6 +1009,7 @@ def main(argv=None):
533
1009
  except Exception:
534
1010
  pass
535
1011
  args = build_parser().parse_args(argv)
1012
+ args.human = resolve_human(args.human)
536
1013
  try:
537
1014
  state = load_state(args.state, args.ledger)
538
1015
  except (OSError, ValueError, RuntimeError) as exc:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "uscha",
4
- "version": "1.88.0",
4
+ "version": "1.90.0",
5
5
  "displayName": "Uscha",
6
6
  "description": "Spec-driven development for LLM coding agents: 9 skills (discovery, adr-refine, reverse-discovery, characterize, devloop, sysdoc, rubric, mirador, status) + a stdlib measurement engine (qa_ledger.py, 52 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
7
7
  "author": {