@andresmassello/uscha 1.88.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
- 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
  """
@@ -37,6 +41,26 @@ FEED_MAX = 8 # = the engine's events_tail length; a short terminal sh
37
41
  BURNUP_MAX = 24
38
42
  MIN_REFRESH = 0.5 # a poll faster than this is a busy loop, not a refresh
39
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)")
63
+
40
64
  # ANSI SGR by obligation state. TRACED and TAGGED deliberately share the UNMEASURED gray:
41
65
  # the v0.1 engine has no source for either rung (ADR-032), so they must read as "not
42
66
  # measured", never as PASS (INV-TOP-02, AC-T-08).
@@ -189,14 +213,23 @@ def _colorize(line, state):
189
213
  return line.replace(state, "\x1b[%sm%s%s" % (code, state, RESET), 1)
190
214
 
191
215
 
192
- def render(state, size, sel=0, plain=True):
193
- """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`.
194
218
 
195
219
  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.
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.
199
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=""):
200
233
  cols, rows = size
201
234
  cols = max(20, int(cols))
202
235
  rows = max(CHROME_LINES + 1, int(rows))
@@ -276,9 +309,19 @@ def render(state, size, sel=0, plain=True):
276
309
  # sent: a feed that silently drops lines is a feed that can hide the red one.
277
310
  out.append("feed %s %d/%d %s newest first %s P/F/H/U/I = pass/fail/human/"
278
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)
279
319
  for i in range(feed_n):
280
320
  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 "
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 "
282
325
  "[d]/[o] phase 2" % (MID, MID, MID, MID))
283
326
  # a coloured line was already fitted BEFORE its escape bytes went in (table rows and
284
327
  # feed lines both), and re-fitting it here would count those bytes as visible width --
@@ -290,6 +333,154 @@ def render(state, size, sel=0, plain=True):
290
333
  return out
291
334
 
292
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
+
293
484
  # --------------------------------------------------------------------------- #
294
485
  # state loading (the ONE read boundary -- it shells out, it never re-derives) #
295
486
  # --------------------------------------------------------------------------- #
@@ -403,6 +594,50 @@ def wait_key(timeout):
403
594
  termios.tcsetattr(fd, termios.TCSADRAIN, saved)
404
595
 
405
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
+
406
641
  def _changed(paths, seen):
407
642
  """(changed?, new snapshot) for a set of files, by (mtime, size).
408
643
 
@@ -430,6 +665,117 @@ def watch_paths(args):
430
665
  return [args.state] if getattr(args, "state", None) else [getattr(args, "ledger", None)]
431
666
 
432
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
+
433
779
  def dispatch(key, sel, count):
434
780
  """Key -> (new selection, quit?, reload?). Pure, so the keymap is testable without a
435
781
  terminal: the driver below is not what is under test, this dispatch is (ADR-034)."""
@@ -466,8 +812,33 @@ def _reload(state, args):
466
812
  return state
467
813
 
468
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
+
469
836
  def _loop(state, args):
470
- 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
471
842
  interval = max(MIN_REFRESH, float(args.refresh or 0))
472
843
  paths = watch_paths(args)
473
844
  _seed, seen = _changed(paths, {}) # the first frame is already current
@@ -477,7 +848,8 @@ def _loop(state, args):
477
848
  while True:
478
849
  if dirty:
479
850
  frame = render(state, terminal_size(args.cols, args.rows),
480
- sel=sel, plain=False)
851
+ sel=(vsel if mode == MODE_VERDICTS else sel), plain=False,
852
+ mode=mode, status=status)
481
853
  sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(frame))
482
854
  sys.stdout.flush()
483
855
  dirty = False
@@ -485,13 +857,40 @@ def _loop(state, args):
485
857
  # `--refresh` tick that re-reads only when a watched file actually moved.
486
858
  key = wait_key(interval)
487
859
  if key:
488
- sel, quit_now, reload_now = dispatch(
489
- key, sel, len(state.get("obligations") or []))
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)
490
871
  if quit_now:
491
872
  return 0
492
- if reload_now:
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:
493
885
  state = _reload(state, args)
494
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
495
894
  dirty = True
496
895
  continue
497
896
  moved, seen = _changed(paths, seen)
@@ -521,6 +920,12 @@ def build_parser():
521
920
  parser.add_argument("--refresh", type=float, default=2.0,
522
921
  help="seconds between mtime polls of the ledger (default: 2, "
523
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")
524
929
  parser.add_argument("--cols", type=int, default=None)
525
930
  parser.add_argument("--rows", type=int, default=None)
526
931
  return parser
@@ -533,6 +938,7 @@ def main(argv=None):
533
938
  except Exception:
534
939
  pass
535
940
  args = build_parser().parse_args(argv)
941
+ args.human = resolve_human(args.human)
536
942
  try:
537
943
  state = load_state(args.state, args.ledger)
538
944
  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.89.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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uscha",
3
- "version": "1.88.0",
3
+ "version": "1.89.0",
4
4
  "description": "Uscha spec-driven development methodology for coding agents. Includes npm/npx router.",
5
5
  "author": {
6
6
  "name": "Andres Massello",
@@ -1,6 +1,6 @@
1
1
  # uscha-kit
2
2
 
3
- **Kit version:** v1.88.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
3
+ **Kit version:** v1.89.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
4
4
 
5
5
  Spec-driven orchestrator + multi-repo QA for Claude Code, with a deterministic ledger.
6
6
  **Nine skills** (`uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`,
package/uscha-kit/VERSION CHANGED
@@ -1 +1 @@
1
- uscha-kit 1.88.0
1
+ uscha-kit 1.89.0
@@ -836,6 +836,12 @@ def cmd_top(args):
836
836
  "--refresh", str(args.refresh)]
837
837
  if args.once:
838
838
  cmd.append("--once")
839
+ # the verdict's author travels from the launcher too (1.89.0): the person at the keyboard
840
+ # is who the record names, and an SSH or multi-user session cannot be resolved from the
841
+ # environment of whichever process happens to run `curate` (ADR-033). Absent -> not passed,
842
+ # and uscha_top.py falls back to $USERNAME/$USER, then to curate's own default.
843
+ if getattr(args, "human", None):
844
+ cmd += ["--human", args.human]
839
845
  rc = subprocess.call(cmd)
840
846
  if rc:
841
847
  raise SystemExit(rc)
@@ -1043,7 +1049,8 @@ def build_parser():
1043
1049
  top = sub.add_parser("top", help="live terminal board of the project's obligations, read from QA-LEDGER.json")
1044
1050
  top.add_argument("--ledger", default="QA-LEDGER.json", help="ledger to read (default: the QA-LEDGER.json convention)")
1045
1051
  top.add_argument("--once", action="store_true", help="print one plain frame and exit (implied without a TTY)")
1046
- top.add_argument("--refresh", type=float, default=2.0, help="seconds between polls (reserved for M2; M1 re-reads on the `r` key)")
1052
+ top.add_argument("--refresh", type=float, default=2.0, help="seconds between mtime polls of the ledger (default: 2, floor 0.5); `r` still forces a re-read")
1053
+ top.add_argument("--human", default=None, help="who is at the keyboard: the name recorded on every verdict this session writes (default: $USERNAME/$USER, then `curate`'s own default)")
1047
1054
  top.add_argument("--json", action="store_true", help="print the engine's read-only `top --json` contract instead of rendering it")
1048
1055
  top.set_defaults(func=cmd_top)
1049
1056
  return parser
@@ -1 +1 @@
1
- {"AC-T-01": true, "AC-T-02": true, "AC-T-03": true, "AC-T-10": true, "AC-T-04": true, "AC-T-05": true, "AC-T-06": true, "AC-T-09": true, "AC-T-24": true, "reg-quarantine-obs-null-on-measured": true, "reg-spec-pin-null-outside-worktree": true, "reg-unreachable-repo-named-not-silent": true, "reg-top-events-malformed-fields-degrade": true, "AC-T-11": true, "AC-T-19": true, "reg-empty-project-honest": true, "AC-T-23": true, "AC-T-21": true, "AC-T-08": true, "AC-T-07": true, "AC-T-18": true, "AC-T-20": true, "AC-T-22": true, "reg-ledger-not-found": true, "AC-T-12": true, "reg-top-render-state-text-cannot-widen-or-escape": true}
1
+ {"AC-T-01": true, "AC-T-02": true, "AC-T-03": true, "AC-T-10": true, "AC-T-04": true, "AC-T-05": true, "AC-T-06": true, "AC-T-09": true, "AC-T-24": true, "reg-quarantine-obs-null-on-measured": true, "reg-spec-pin-null-outside-worktree": true, "reg-unreachable-repo-named-not-silent": true, "reg-top-events-malformed-fields-degrade": true, "AC-T-11": true, "AC-T-19": true, "reg-empty-project-honest": true, "AC-T-23": true, "AC-T-21": true, "AC-T-08": true, "AC-T-07": true, "AC-T-18": true, "AC-T-20": true, "AC-T-22": true, "reg-ledger-not-found": true, "AC-T-12": true, "reg-top-render-state-text-cannot-widen-or-escape": true, "AC-T-13": true, "AC-T-14": true, "AC-T-15": true, "AC-T-16": true, "AC-T-17": true}