@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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +69 -22
- package/uscha-kit/.claude/skills/uscha-devloop/uscha_top.py +419 -13
- package/uscha-kit/.claude-plugin/plugin.json +1 -1
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +1 -1
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/install-uscha.py +8 -1
- package/uscha-kit/reports/junit/.top-cases.json +1 -1
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +69 -22
- package/uscha-kit/skills/uscha-devloop/uscha_top.py +419 -13
- package/uscha-kit/uscha.config.json +1 -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
|
-
|
|
17
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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
|
|
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.
|
|
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": {
|
package/uscha-kit/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# uscha-kit
|
|
2
2
|
|
|
3
|
-
**Kit version:** v1.
|
|
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.
|
|
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
|
|
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}
|