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