@andresmassello/uscha 1.89.0 → 1.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +13 -9
  2. package/package.json +1 -1
  3. package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +221 -18
  4. package/uscha-kit/.claude/skills/uscha-devloop/uscha_top.py +438 -26
  5. package/uscha-kit/.claude-plugin/plugin.json +1 -1
  6. package/uscha-kit/.codex-plugin/plugin.json +1 -1
  7. package/uscha-kit/README.md +2 -6
  8. package/uscha-kit/VERSION +1 -1
  9. package/uscha-kit/install-uscha.py +5 -0
  10. package/uscha-kit/skills/uscha-devloop/qa_ledger.py +221 -18
  11. package/uscha-kit/skills/uscha-devloop/uscha_top.py +438 -26
  12. package/uscha-kit/uscha.config.json +1 -1
  13. package/uscha-kit/reports/junit/.bench-cases.json +0 -1
  14. package/uscha-kit/reports/junit/.bench-curate-cases.json +0 -1
  15. package/uscha-kit/reports/junit/.bootstrap-cases.json +0 -1
  16. package/uscha-kit/reports/junit/.cleanroom-cases.json +0 -1
  17. package/uscha-kit/reports/junit/.compile-cases.json +0 -1
  18. package/uscha-kit/reports/junit/.curation-cases.json +0 -1
  19. package/uscha-kit/reports/junit/.delta-cases.json +0 -1
  20. package/uscha-kit/reports/junit/.fa-cases.json +0 -1
  21. package/uscha-kit/reports/junit/.facts-cases.json +0 -1
  22. package/uscha-kit/reports/junit/.fastpath-cases.json +0 -1
  23. package/uscha-kit/reports/junit/.fidelity-cases.json +0 -1
  24. package/uscha-kit/reports/junit/.goldencov-cases.json +0 -1
  25. package/uscha-kit/reports/junit/.ir-cases.json +0 -1
  26. package/uscha-kit/reports/junit/.js-cases.json +0 -1
  27. package/uscha-kit/reports/junit/.lang-cases.json +0 -1
  28. package/uscha-kit/reports/junit/.lang3-cases.json +0 -1
  29. package/uscha-kit/reports/junit/.multi-cases.json +0 -1
  30. package/uscha-kit/reports/junit/.oracle-cases.json +0 -1
  31. package/uscha-kit/reports/junit/.origin-cases.json +0 -1
  32. package/uscha-kit/reports/junit/.r2-cases.json +0 -1
  33. package/uscha-kit/reports/junit/.rt-cases.json +0 -1
  34. package/uscha-kit/reports/junit/.sched-cases.json +0 -1
  35. package/uscha-kit/reports/junit/.specdrift-cases.json +0 -1
  36. package/uscha-kit/reports/junit/.top-cases.json +0 -1
@@ -20,6 +20,18 @@ TUI never opens the ledger for writing and never builds a curation record, so it
20
20
  drift from the record shape the engine owns. It records a judgement; it does not promote,
21
21
  does not rerun, and never moves DONE (INV-TOP-03).
22
22
 
23
+ M4 scope (phase 2, ADR-037): `d` opens a READ-ONLY spec↔code drift pane over `spec_diff`, the
24
+ advisory record `qa_ledger.py spec-drift` already left in the ledger -- it measures nothing and
25
+ runs nothing; with no recorded run the pane says so instead of showing a clean board. `o` reruns
26
+ the command THE HUMAN supplied at launch (`--rerun-cmd`, ADR-008 style: the tool never guesses a
27
+ test command) and then lets the engine's own `snapshot` ingest whatever the run produced -- so the
28
+ board still moves only on measured evidence, and the TUI still writes nothing itself. Three spawns
29
+ exist BEYOND THE READ BOUNDARY and no more: `curate` (a verdict), `snapshot` (the ingest) and the
30
+ human's own command, one per keypress, none inside a loop. The read boundary itself -- the one
31
+ `top --json` call in `load_state` -- is a fourth `subprocess.run` in this module and always was;
32
+ counting it among the three read as a false claim to anyone who grepped (1.91.0 blind review), so
33
+ the sentence now says which side of the boundary it counts. Four call sites total, no fifth.
34
+
23
35
  Stdlib only. Python 3.8+. Runnable directly or via `python -m uscha_top`.
24
36
  """
25
37
 
@@ -30,6 +42,7 @@ import shutil
30
42
  import subprocess
31
43
  import sys
32
44
  import time
45
+ import unicodedata
33
46
 
34
47
  DEFAULT_LEDGER = "QA-LEDGER.json"
35
48
  FALLBACK_SIZE = (100, 32)
@@ -43,6 +56,11 @@ MIN_REFRESH = 0.5 # a poll faster than this is a busy loop, not a refresh
43
56
 
44
57
  MODE_BOARD = "board"
45
58
  MODE_VERDICTS = "verdicts"
59
+ MODE_DIFF = "diff"
60
+ # DIFF geometry: title, rule, the measurement line, the rule under it, the table header, the
61
+ # rule above the status line, the status line, the key hint. The rest is drift rows.
62
+ DIFF_CHROME = 8
63
+ DIFF_HINT = "advisory (ADR-005) -- a stale spec is a conversation, never a gate"
46
64
  # VERDICTS geometry: title, rule, the pending line, the rule under the list, the rule under
47
65
  # the pane, the status line, the key hint. Everything else is queue rows + the detail pane.
48
66
  VERDICT_CHROME = 7
@@ -61,6 +79,18 @@ VERDICT_COOLDOWN = 0.25
61
79
  VERDICT_COOLDOWN_MSG = ("verdict recorded -- release the key (the queue advanced; the next "
62
80
  "observation is a new judgement)")
63
81
 
82
+ # `o` (ADR-037, option B). The command is NEVER guessed and never read from config: it is the
83
+ # shell string the human passed at launch, the same discipline `cleanroom --run` follows
84
+ # (ADR-008). Without it the key is inert and says why.
85
+ RERUN_MISSING_MSG = "no rerun command given -- pass --rerun-cmd"
86
+ # The same courtesy a refused verdict key gets: a held `o` is one rerun, and the presses the
87
+ # cooldown eats must SAY they were eaten. A keypress that vanishes silently reads as a dropped
88
+ # input, and the next reflex is to press it again -- the repeat the cooldown exists to stop.
89
+ RERUN_COOLDOWN_MSG = ("rerun in progress or just finished -- release the key (the board "
90
+ "reloaded; press `o` again to run it once more)")
91
+ RERUN_FROZEN_MSG = "--state is a frozen snapshot -- a rerun needs a live ledger"
92
+ RERUN_NO_REPO_MSG = "no repo configured in this ledger -- nothing to rerun and nothing to ingest"
93
+
64
94
  # ANSI SGR by obligation state. TRACED and TAGGED deliberately share the UNMEASURED gray:
65
95
  # the v0.1 engine has no source for either rung (ADR-032), so they must read as "not
66
96
  # measured", never as PASS (INV-TOP-02, AC-T-08).
@@ -105,21 +135,76 @@ ACTIONS = {
105
135
  # --------------------------------------------------------------------------- #
106
136
  # pure rendering #
107
137
  # --------------------------------------------------------------------------- #
138
+ def _dw(text):
139
+ """Display width in TERMINAL COLUMNS, not codepoints.
140
+
141
+ `len()` counts codepoints, and the frame's whole contract is columns: a CJK project name
142
+ or a full-width event text takes two columns per codepoint, so a line `len()` called
143
+ exactly `cols` wide draws twice that and the frame every golden pins stops being a frame
144
+ (1.86.1 fresh review, LOW, deferred until a fixture existed -- `state-wide.json` is it).
145
+
146
+ Three classes, and no font metric anywhere: East Asian Wide and Fullwidth cost 2, a
147
+ combining mark costs 0 (it draws on the previous cell), everything else costs 1. East
148
+ Asian *Ambiguous* deliberately counts 1 -- that is the class the renderer's own glyphs
149
+ fall into (`…`, `·`, `─`, `│`, `▁`, `—`), so on an ASCII state `_dw` is `len` and every
150
+ frame captured before this function existed stays byte-identical."""
151
+ width = 0
152
+ for ch in str(text):
153
+ if unicodedata.combining(ch):
154
+ continue
155
+ width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
156
+ return width
157
+
158
+
159
+ def _cut(text, width):
160
+ """The longest PREFIX of `text` that fits in `width` columns, whole characters only.
161
+
162
+ A wide character is never split: half a glyph is not half a column, it is a cell the
163
+ terminal fills however it likes and a frame nobody can snapshot."""
164
+ if width <= 0:
165
+ return ""
166
+ out, used = [], 0
167
+ for ch in str(text):
168
+ w = 0 if unicodedata.combining(ch) else (
169
+ 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1)
170
+ if used + w > width:
171
+ break
172
+ out.append(ch)
173
+ used += w
174
+ return "".join(out)
175
+
176
+
177
+ def _pad(text, width):
178
+ """`str.ljust` measured in columns. Padding by codepoints puts a wide cell's second
179
+ column inside the next field and every column after it walks."""
180
+ return str(text) + " " * max(0, width - _dw(text))
181
+
182
+
183
+ def _rjust(text, width):
184
+ """`str.rjust` measured in columns -- `_pad`'s mirror, for the numeric columns."""
185
+ return " " * max(0, width - _dw(text)) + str(text)
186
+
187
+
108
188
  def _fit(text, cols):
109
189
  """One line, never wider than the terminal. Wrapping would break the frame's row
110
- accounting, so an over-long line is cut and marked."""
190
+ accounting, so an over-long line is cut and marked.
191
+
192
+ Measured and cut in COLUMNS (`_dw`/`_cut`): cutting by codepoints was the bug. Note the
193
+ cut may leave one column short rather than land exactly on `cols - 1` -- when the
194
+ character at the boundary is wide it is dropped whole, and a frame one column narrow is
195
+ correct where a frame one column wide is not."""
111
196
  if cols <= 0:
112
197
  return ""
113
- if len(text) <= cols:
198
+ if _dw(text) <= cols:
114
199
  return text
115
- return text[:cols - 1] + "…" if cols > 1 else text[:cols]
200
+ return _cut(text, cols - 1) + "…" if cols > 1 else _cut(text, cols)
116
201
 
117
202
 
118
203
  def _spread(left, right, cols):
119
204
  """left ... right on one line, right-aligned, degrading to just `left` when tight."""
120
- if len(left) + len(right) + 1 > cols:
205
+ if _dw(left) + _dw(right) + 1 > cols:
121
206
  return _fit(left, cols)
122
- return left + " " * (cols - len(left) - len(right)) + right
207
+ return left + " " * (cols - _dw(left) - _dw(right)) + right
123
208
 
124
209
 
125
210
  def _num(value):
@@ -151,7 +236,7 @@ def _burnup_line(burnup, cols):
151
236
  note = " (readiness score, not closed obligations)"
152
237
  if not points:
153
238
  return label + DASH + " (no `readiness --record` history yet)"
154
- room = max(4, min(BURNUP_MAX, cols - len(label) - len(note)))
239
+ room = max(4, min(BURNUP_MAX, cols - _dw(label) - _dw(note)))
155
240
  bars = "".join(BLOCKS[min(len(BLOCKS) - 1, max(0, int(p) * len(BLOCKS) // 101))]
156
241
  for p in points[-room:])
157
242
  return label + bars + note
@@ -180,10 +265,13 @@ def _cases_text(ob):
180
265
 
181
266
 
182
267
  def _row(ob, selected):
268
+ # the three left columns are cut and padded in COLUMNS: an id or state carrying wide
269
+ # characters used to eat its neighbour's field and walk every column after it.
183
270
  gutter = "> " if selected else " "
184
- return "%s%-8s%-9s%-15s%7s%5s %s" % (
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),
271
+ return "%s%s%s%s%7s%5s %s" % (
272
+ gutter, _pad(_cut(_safe(ob.get("id") or "?"), 8), 8),
273
+ _pad(_cut(_safe(ob.get("gate") or DASH), 8), 9),
274
+ _pad(_cut(_safe(ob.get("state") or "?"), 14), 15), _cases_text(ob),
187
275
  _num(ob.get("age_hours")), ACTIONS.get(ob.get("state"), DASH))
188
276
 
189
277
 
@@ -191,8 +279,22 @@ def _safe(text):
191
279
  """No control character reaches the terminal through the feed. The engine already
192
280
  strips them where the text is derived (`_top_event_text`); this is the second guard on
193
281
  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)
282
+ and one ESC in it would be a control sequence the board obeys instead of prints.
283
+
284
+ Dropped (1.90.0), matching the engine's `_top_clean` exactly: C0 and DEL, the C1 range
285
+ U+0080-U+009F (a terminal reading the stream as latin-1 takes those for CSI/OSC), and
286
+ every Unicode format character (category `Cf`) -- U+200B costs a codepoint and no column,
287
+ U+202E reverses everything after it. A character that cannot be seen must not be able to
288
+ move what is."""
289
+ out = []
290
+ for ch in str(text or ""):
291
+ code = ord(ch)
292
+ if code < 32 or code == 127 or 0x80 <= code <= 0x9F:
293
+ continue
294
+ if unicodedata.category(ch) == "Cf":
295
+ continue
296
+ out.append(ch)
297
+ return "".join(out)
196
298
 
197
299
 
198
300
  def _feed_line(ev, cols, plain):
@@ -226,6 +328,8 @@ def render(state, size, sel=0, plain=True, mode=MODE_BOARD, status=""):
226
328
  """
227
329
  if mode == MODE_VERDICTS:
228
330
  return _render_verdicts(state, size, sel, plain, status)
331
+ if mode == MODE_DIFF:
332
+ return _render_diff(state, size, sel, plain, status)
229
333
  return _render_board(state, size, sel, plain, status)
230
334
 
231
335
 
@@ -318,11 +422,12 @@ def _render_board(state, size, sel, plain, status=""):
318
422
  out[-1] = _fit("status %s %s" % (MID, _safe(status)), cols)
319
423
  for i in range(feed_n):
320
424
  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 "
325
- "[d]/[o] phase 2" % (MID, MID, MID, MID))
425
+ # every key on this line WORKS as of 1.91.0: `[v]` lost its `(M3)` marker when verdicts
426
+ # shipped, and `[d]/[o]` lose their `phase 2` marker here for the same reason. A hint that
427
+ # labels a live key as future is the same class of stale claim the frames exist to catch --
428
+ # and one that labels a dead key as live is the worse half of it.
429
+ out.append("[j/k] move %s [r] reload %s [q] quit %s [v] verdicts %s [d]iff %s [o] rerun"
430
+ % (MID, MID, MID, MID, MID))
326
431
  # a coloured line was already fitted BEFORE its escape bytes went in (table rows and
327
432
  # feed lines both), and re-fitting it here would count those bytes as visible width --
328
433
  # cutting the coloured frame ~9 characters shorter than the plain one it is supposed to
@@ -355,14 +460,17 @@ def _wrap(text, width):
355
460
  for word in _safe(text).split():
356
461
  if not line:
357
462
  line = word
358
- elif len(line) + 1 + len(word) <= width:
463
+ elif _dw(line) + 1 + _dw(word) <= width:
359
464
  line += " " + word
360
465
  else:
361
466
  out.append(line)
362
467
  line = word
363
- while len(line) > width:
364
- out.append(line[:width])
365
- line = line[width:]
468
+ # the hard split is measured in columns too, and `_cut` never breaks a wide
469
+ # character in half -- so a wide token continues on the next line, whole.
470
+ while _dw(line) > width:
471
+ head = _cut(line, width)
472
+ out.append(head)
473
+ line = line[len(head):]
366
474
  if line:
367
475
  out.append(line)
368
476
  return out or [""]
@@ -374,10 +482,10 @@ def _obs_row(i, ob, selected, cols):
374
482
  gutter = "> " if selected else " "
375
483
  idx = "[%d]" % (i + 1) if i < VERDICT_LIST_MAX else " "
376
484
  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))
485
+ head = "%s%-4s%s" % (gutter, idx, _pad(_cut(_safe(ob.get("id")), 18), 18))
486
+ room = max(4, cols - _dw(head) - _dw(tail))
379
487
  title = _fit(_safe(ob.get("title")) or DASH, room)
380
- return head + title.ljust(room) + tail
488
+ return head + _pad(title, room) + tail
381
489
 
382
490
 
383
491
  def _column_widths(cols):
@@ -412,7 +520,7 @@ def _pane(ob, cols, height):
412
520
  pad = max(len(left), len(right))
413
521
  left += [""] * (pad - len(left))
414
522
  right += [""] * (pad - len(right))
415
- body = head + [(" %s │ %s" % (l.ljust(lw), r)).rstrip()
523
+ body = head + [(" %s │ %s" % (_pad(l, lw), r)).rstrip()
416
524
  for l, r in zip(left, right)]
417
525
  else:
418
526
  body = (head + [" CANDIDATE"] + [" " + ln for ln in block("candidate", cols - 2)]
@@ -481,6 +589,150 @@ def _render_verdicts(state, size, sel, plain, status):
481
589
  return out[:rows] + [""] * max(0, rows - len(out))
482
590
 
483
591
 
592
+ # --------------------------------------------------------------------------- #
593
+ # DIFF mode -- spec <-> code drift, read-only (ADR-037 phase 2) #
594
+ # --------------------------------------------------------------------------- #
595
+ def first_repo(state):
596
+ """The repo this session acts on: the FIRST configured one, which is the same repo
597
+ `spec_pin` already labels the board with (ADR-032). Returns (name, path) or (None, None).
598
+
599
+ The choice is the engine's, not the TUI's -- `repos[]` arrives in configuration order and
600
+ this only takes the head of it. A multi-repo project therefore reruns and ingests ONE
601
+ repo, the first, and every line that depends on the choice says which repo it picked
602
+ rather than leaving the reader to assume it was all of them."""
603
+ repos = [r for r in (state.get("repos") or []) if isinstance(r, dict) and r.get("name")]
604
+ if not repos:
605
+ return None, None
606
+ return _safe(repos[0]["name"]), _safe(repos[0].get("path") or ".")
607
+
608
+
609
+ def _lag_text(value):
610
+ """A lag in days, or the em dash when the record carries none. `%g` so a whole number of
611
+ days reads as `60` and a fractional one keeps its tenth -- the record rounds to one
612
+ decimal and this neither adds precision nor drops it."""
613
+ if not isinstance(value, (int, float)):
614
+ return DASH
615
+ return "%g" % value
616
+
617
+
618
+ def _cut_tail(text, width):
619
+ """The longest SUFFIX of `text` that fits in `width` columns, whole characters only.
620
+ `_cut` read backwards -- same no-split-a-wide-glyph rule, same column arithmetic."""
621
+ if width <= 0:
622
+ return ""
623
+ out, used = [], 0
624
+ for ch in reversed(str(text)):
625
+ w = 0 if unicodedata.combining(ch) else (
626
+ 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1)
627
+ if used + w > width:
628
+ break
629
+ out.append(ch)
630
+ used += w
631
+ return "".join(reversed(out))
632
+
633
+
634
+ def _fit_tail(text, cols):
635
+ """Like `_fit`, but it keeps the END of the string. Used only for paths: a governed file
636
+ cut at the front still shows the file that moved, cut at the back it shows a directory.
637
+
638
+ Measured and cut in COLUMNS (`_dw`/`_cut_tail`), like `_fit`: `len()` and slicing count
639
+ codepoints, and a CJK path component draws two columns per codepoint -- the DIFF pane was
640
+ the one surface still measuring itself in codepoints after 1.90.0 fixed the board. As
641
+ with `_fit`, the cut may land one column short rather than exactly on `cols - 1` when the
642
+ character at the boundary is wide: one column narrow keeps the frame, one column wide
643
+ does not."""
644
+ text = _safe(text)
645
+ if cols <= 0:
646
+ return ""
647
+ if _dw(text) <= cols:
648
+ return text
649
+ return "…" + _cut_tail(text, cols - 1) if cols > 1 else _cut_tail(text, cols)
650
+
651
+
652
+ def _diff_widths(cols):
653
+ """` <doc> <lag> <code ref>` -- 2 for the gutter, 2+2 for the separators, 6 for LAG."""
654
+ lag = 6
655
+ code = max(10, (cols - 4 - lag - 2) // 2)
656
+ doc = max(10, cols - 4 - lag - 2 - code)
657
+ return doc, lag, code
658
+
659
+
660
+ def _diff_head(diff, state):
661
+ """The one line that says WHEN this was measured, or that nobody measured it.
662
+
663
+ The honest empty case is the whole point of the pane: with no `spec-drift` record the
664
+ board must not read as "no drift" -- it says there is no run and names the command that
665
+ would produce one (INV-TOP-05, the same rule the feed's empty label follows)."""
666
+ if not diff:
667
+ repo, _path = first_repo(state)
668
+ return ("no spec-drift run recorded -- run `qa_ledger.py spec-drift --repo %s`"
669
+ % (repo or "<repo>"))
670
+ stale = [s for s in (diff.get("stale") or []) if isinstance(s, dict)]
671
+ lag = diff.get("max_lag_days")
672
+ # the COUNT sits before the timestamp on purpose: at the 80-column floor this line is the
673
+ # one that gets cut, and "2 of 4 stale" is the fact the reader came for.
674
+ return ("spec %s code drift %s advisory %s %d of %d doc(s) stale (lag > %s d) %s "
675
+ "measured %s" % ("↔", MID, MID, len(stale), diff.get("docs_total") or 0,
676
+ _num(lag), MID, _safe(diff.get("measured_at")) or DASH))
677
+
678
+
679
+ def _render_diff(state, size, sel, plain, status):
680
+ """The spec↔code drift pane: `spec_diff` drawn, nothing derived and nothing run.
681
+
682
+ Read-only twice over. `d` never invokes `spec-drift` (that command walks git and WRITES
683
+ its latest-state record; this pane is a projection of that record, ADR-037), and `render`
684
+ performs no I/O at all (ADR-034). What is on screen is what the last real run measured,
685
+ with its own timestamp beside it so an old measurement cannot pass for a fresh one.
686
+
687
+ `sel` and `plain` are accepted and unused: this pane has no cursor (v1 shows the worst
688
+ lags first and NAMES the shortfall rather than scrolling) and no colour of its own -- a
689
+ green/red here would read as a gate, and ADR-005 drift never gates."""
690
+ cols, rows = size
691
+ cols = max(20, int(cols))
692
+ rows = max(DIFF_CHROME + 1, int(rows))
693
+ diff = state.get("spec_diff") if isinstance(state.get("spec_diff"), dict) else None
694
+ doc_w, lag_w, code_w = _diff_widths(cols)
695
+
696
+ out = [_spread("uscha top %s %s %s spec drift"
697
+ % (MID, _safe(state.get("project")) or "(unnamed project)", MID),
698
+ "step #%s" % _safe(_num(state.get("step"))), cols),
699
+ RULE * cols,
700
+ _fit(_diff_head(diff, state), cols),
701
+ RULE * cols,
702
+ " %s %s %s" % (_pad("DOC", doc_w), _rjust("LAG/d", lag_w),
703
+ "A NEWER GOVERNED FILE")]
704
+
705
+ body = []
706
+ stale = [s for s in ((diff or {}).get("stale") or []) if isinstance(s, dict)]
707
+ for s in stale:
708
+ ref = _fit_tail(s.get("code_ref") or DASH, code_w)
709
+ more = s.get("newer_files_total")
710
+ if isinstance(more, int) and more > 1:
711
+ # the row shows ONE file of the N that outran the doc, and says so: a single
712
+ # path with no cardinality beside it reads as "one file changed".
713
+ ref = _fit(ref + " (1 of %d)" % more, code_w)
714
+ body.append(" %s %s %s" % (_pad(_fit(_safe(s.get("doc")) or "?", doc_w), doc_w),
715
+ _rjust(_lag_text(s.get("lag_days")), lag_w), ref))
716
+ if not diff:
717
+ body = [" nothing to show until a spec-drift run is recorded %s `d` reads that "
718
+ "record, it never runs it" % MID]
719
+ elif not stale:
720
+ body = [" no spec document is stale at this lag %s every one reads CLEAN, unmapped "
721
+ "or untracked" % MID]
722
+
723
+ avail = rows - DIFF_CHROME
724
+ if len(body) > avail:
725
+ body = body[:max(0, avail - 1)] + [" %s %d more stale doc(s) do not fit at this size "
726
+ "(worst lag first)" % (DASH, len(body) - avail + 1)]
727
+ out.extend(body[:avail])
728
+ out.extend([""] * max(0, avail - len(body)))
729
+ out.append(RULE * cols)
730
+ out.append(_fit("status %s %s" % (MID, _safe(status) or DIFF_HINT), cols))
731
+ out.append(_fit("[t]/[Esc] back %s [r] reload %s [q] quit" % (MID, MID), cols))
732
+ out = [line if "\x1b" in line else _fit(line, cols) for line in out]
733
+ return out[:rows] + [""] * max(0, rows - len(out))
734
+
735
+
484
736
  # --------------------------------------------------------------------------- #
485
737
  # state loading (the ONE read boundary -- it shells out, it never re-derives) #
486
738
  # --------------------------------------------------------------------------- #
@@ -724,6 +976,85 @@ def apply_verdict(ob, verdict, args, engine=None):
724
976
  return False, said or ("curate exited %s -- nothing was recorded" % rc)
725
977
 
726
978
 
979
+ def _rerun_call(cmd, cwd):
980
+ """The human's OWN command, run in the tracked repo's directory (ADR-037, option B).
981
+
982
+ `shell=True` is the decision, not an oversight: what arrives here is the shell string the
983
+ human typed after `--rerun-cmd` (`pytest -q && npm test`), exactly the way `cleanroom
984
+ --run` and `golden-coverage --harness` take theirs -- the engine never decides what to
985
+ run, and neither does this TUI (ADR-008). The trust boundary is the human's own shell,
986
+ which ADR-037 states rather than pretends to mitigate: a misspelt flag runs whatever the
987
+ shell makes of it, in that directory, the same as typing it there.
988
+
989
+ Output is NOT captured: a test suite writes to the terminal the human is watching, and
990
+ swallowing it would replace measured output with a spinner. One function on purpose --
991
+ it is the boundary the suite replaces to assert the command and the ONE call per keypress
992
+ without running anything (AC-T-26). Returns the exit code."""
993
+ return subprocess.run(cmd, shell=True, cwd=cwd).returncode
994
+
995
+
996
+ def _snapshot_call(engine, ledger, repo):
997
+ """The INGEST, made by the engine's own `snapshot` -- the same subcommand the dev loop
998
+ runs at every pass close. This is what makes `o` honest: the board moves on ingested
999
+ evidence or it does not move at all, and the TUI still builds no record of its own
1000
+ (ADR-033's rule, one more engine subcommand under it -- ADR-037).
1001
+
1002
+ Returns (returncode, the engine's own last line)."""
1003
+ argv = [sys.executable, engine, "snapshot", "--ledger", ledger, "--repo", repo]
1004
+ proc = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1005
+ said = ((proc.stderr or b"").decode("utf-8", "replace").strip().splitlines()
1006
+ or (proc.stdout or b"").decode("utf-8", "replace").strip().splitlines())
1007
+ return proc.returncode, (said[-1] if said else "")
1008
+
1009
+
1010
+ def rerun_banner(cmd, repo):
1011
+ """What the board says WHILE the command runs. Pure, so the frame that carries it stays
1012
+ a pure function of its inputs; the loop draws it before it blocks."""
1013
+ return ("rerun: %s %s running in %s (first configured repo) %s verdict keys locked"
1014
+ % (_safe(cmd), MID, _safe(repo), MID))
1015
+
1016
+
1017
+ def run_rerun(state, args, engine=None):
1018
+ """One keypress -> the human's command, then the engine's ingest, then a reload upstream.
1019
+ Returns (ran?, the line the status bar shows).
1020
+
1021
+ The order is the decision: the snapshot runs **whether or not the command exited 0**. A
1022
+ red suite is evidence too, and the whole point of `o` is that what lands on the board is
1023
+ what a report says, never what an exit code narrated -- refusing to ingest a red run
1024
+ would leave the board showing the previous, greener measurement (measured red beats
1025
+ narrated green, kit 1.48.1). What a non-zero exit changes is the STATUS LINE, which names
1026
+ it, and nothing else.
1027
+
1028
+ Three refusals come first, none of which spawns anything: no `--rerun-cmd` (the command
1029
+ is the human's to supply, ADR-008), a `--state` frozen snapshot (the ledger on disk is
1030
+ not the one on screen -- the same refusal a verdict gets), and a ledger with no configured
1031
+ repo (there is no cwd to run in and no repo to ingest for)."""
1032
+ cmd = getattr(args, "rerun_cmd", None)
1033
+ if not cmd:
1034
+ return False, RERUN_MISSING_MSG
1035
+ if getattr(args, "state", None):
1036
+ return False, RERUN_FROZEN_MSG
1037
+ repo, path = first_repo(state)
1038
+ if not repo:
1039
+ return False, RERUN_NO_REPO_MSG
1040
+ eng = engine or engine_path()
1041
+ if not eng:
1042
+ return False, "qa_ledger.py not found next to uscha_top.py"
1043
+ cwd = os.path.join(os.path.dirname(os.path.realpath(args.ledger)) or ".", path or ".")
1044
+ if not os.path.isdir(cwd):
1045
+ return False, "repo path '%s' does not exist -- nothing was run" % path
1046
+ code = _rerun_call(cmd, cwd)
1047
+ rc, said = _snapshot_call(eng, args.ledger, repo)
1048
+ tail = said or ("snapshot exited %s" % rc)
1049
+ if rc != 0:
1050
+ return True, ("rerun exit %s %s snapshot FAILED (%s) -- nothing was ingested"
1051
+ % (code, MID, tail))
1052
+ if code != 0:
1053
+ # a red run is still a measurement: it is ingested, and the line says both facts.
1054
+ return True, "rerun exit %s (red) %s ingested: %s" % (code, MID, tail)
1055
+ return True, "rerun exit 0 %s ingested: %s" % (MID, tail)
1056
+
1057
+
727
1058
  def after_verdict(sel, count):
728
1059
  """Where the cursor lands once the queue has been re-read: (selection, mode).
729
1060
 
@@ -737,7 +1068,26 @@ def after_verdict(sel, count):
737
1068
  return max(0, min(sel, count - 1)), MODE_VERDICTS
738
1069
 
739
1070
 
740
- def dispatch_mode(key, mode, sel, count, cooling=False):
1071
+ def is_rerun_key(key, mode, cooling=False, rerunning=False):
1072
+ """Is this keypress a rerun request (ADR-037)? A pure predicate, and deliberately NOT a
1073
+ sixth member of `dispatch_mode`'s tuple: that shape is what M3 measured, and widening a
1074
+ measured contract so it can carry a second action is how a keymap grows a second write
1075
+ path nobody counted. The caller spends a True on exactly one `_rerun_call` + one
1076
+ `_snapshot_call`, never a loop (AC-T-29).
1077
+
1078
+ `o` answers on the BOARD only -- the verdicts queue and the drift pane have their own
1079
+ jobs -- and it is refused while a rerun is in flight or while the 250 ms cooldown after
1080
+ one is still running, so a HELD `o` is one rerun and not a queue of them (the same guard
1081
+ a held verdict key gets, ADR-033)."""
1082
+ return key in ("o", "O") and mode == MODE_BOARD and not cooling and not rerunning
1083
+
1084
+
1085
+ # `rerunning=True` is never passed by `_loop`, and that is not an oversight: the rerun is
1086
+ # SYNCHRONOUS (the spawn blocks the loop, and `drain_keys` throws away whatever was typed
1087
+ # meanwhile), so the sync block plus the drain IS the lock -- the flag would have nothing to
1088
+ # guard against. It exists as a MEASURED contract: the predicate is what a future async rerun
1089
+ # would have to honour, and AC-T-27 pins it as a pure function rather than racing a terminal.
1090
+ def dispatch_mode(key, mode, sel, count, cooling=False, rerunning=False):
741
1091
  """The mode machine: key + current mode -> (mode, selection, quit?, reload?, verdict).
742
1092
 
743
1093
  Pure, and the ONE place a keypress becomes a write decision -- `verdict` is a string the
@@ -750,9 +1100,21 @@ def dispatch_mode(key, mode, sel, count, cooling=False):
750
1100
  stays pure). While it is true, `p`/`f`/`u` produce NO verdict: a key held down repeats,
751
1101
  and the second repeat would judge the observation that just took the cursor's place. Every
752
1102
  other key keeps working -- the cooldown blocks writes, not the reader."""
1103
+ if mode == MODE_DIFF:
1104
+ # a read-only pane with a read-only keymap: leave, re-read, or quit. No cursor (the
1105
+ # pane names what does not fit instead of scrolling) and no write of any kind.
1106
+ if key in ("q", "Q", "\x03"):
1107
+ return mode, sel, True, False, None
1108
+ if key in ("t", "T", "\x1b", "d", "D"):
1109
+ return MODE_BOARD, 0, False, False, None
1110
+ if key == "r":
1111
+ return mode, sel, False, True, None
1112
+ return mode, sel, False, False, None
753
1113
  if mode != MODE_VERDICTS:
754
1114
  if key in ("v", "V"):
755
1115
  return MODE_VERDICTS, 0, False, False, None
1116
+ if key in ("d", "D"):
1117
+ return MODE_DIFF, 0, False, False, None
756
1118
  sel, quit_now, reload_now = dispatch(key, sel, count)
757
1119
  return MODE_BOARD, sel, quit_now, reload_now, None
758
1120
  if key in ("q", "Q", "\x03"):
@@ -769,7 +1131,11 @@ def dispatch_mode(key, mode, sel, count, cooling=False):
769
1131
  # an empty queue produces NO verdict: there is nothing selected to judge, and a
770
1132
  # keypress that writes anyway would be a verdict the human never aimed at an OBS.
771
1133
  # 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)
1134
+ # `rerunning` is the same refusal for a different reason (ADR-037): while a rerun is
1135
+ # in flight the queue on screen was read BEFORE it, and a verdict recorded against a
1136
+ # queue the ingest is about to move is a judgement aimed at the wrong observation.
1137
+ return mode, sel, False, False, (VERDICTS[key] if (count and not cooling
1138
+ and not rerunning) else None)
773
1139
  if len(str(key)) == 1 and key in "123456789":
774
1140
  n = int(key) - 1
775
1141
  return mode, (n if n < count else sel), False, False, None
@@ -833,6 +1199,25 @@ def _apply_and_advance(state, args, queue, cur, verdict):
833
1199
  return state, cur, mode, status, True
834
1200
 
835
1201
 
1202
+ def _rerun_and_reload(state, args):
1203
+ """ONE keypress -> ONE command -> ONE `snapshot` -> re-read. Returns (state, status).
1204
+
1205
+ A function of its own for the same structural reason `_apply_and_advance` is one, and the
1206
+ suite asserts it the same way (AC-T-29): the module's single call to `run_rerun` must have
1207
+ no `for`/`while` above it, so no later edit can quietly turn one keypress into a pass over
1208
+ the repos. The re-read afterwards is what makes the new measurement visible; DONE moves
1209
+ here or nowhere, because the ingest is the only thing that can move it (INV-TOP-03).
1210
+
1211
+ The input buffer is drained whether anything ran or not: a suite that takes a minute is
1212
+ exactly when a human types, and those keystrokes belong to the terminal they were typed
1213
+ into, not to the board that comes back."""
1214
+ ran, status = run_rerun(state, args)
1215
+ drain_keys()
1216
+ if not ran:
1217
+ return state, status
1218
+ return _reload(state, args), status
1219
+
1220
+
836
1221
  def _loop(state, args):
837
1222
  sel = 0 # the board's cursor
838
1223
  vsel = 0 # the verdict queue's cursor, kept apart from it
@@ -876,6 +1261,26 @@ def _loop(state, args):
876
1261
  cooldown_until = time.time() + VERDICT_COOLDOWN
877
1262
  if wrote:
878
1263
  _fresh, seen = _changed(paths, seen)
1264
+ elif is_rerun_key(key, mode, cooling=cooling):
1265
+ if getattr(args, "rerun_cmd", None):
1266
+ # the frame the human watches WHILE the command runs, drawn before
1267
+ # the spawn because the spawn blocks this loop until it returns.
1268
+ # That synchronous shape is also why the verdict lock is measured on
1269
+ # `dispatch_mode(..., rerunning=True)` and not raced against a
1270
+ # terminal (AC-T-27): while the suite runs, no key is read at all --
1271
+ # what is typed lands in the buffer and the drain throws it away.
1272
+ sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(render(
1273
+ state, terminal_size(args.cols, args.rows), sel=sel, plain=False,
1274
+ mode=mode, status=rerun_banner(args.rerun_cmd,
1275
+ first_repo(state)[0] or "?"))))
1276
+ sys.stdout.flush()
1277
+ state, status = _rerun_and_reload(state, args)
1278
+ # the same 250 ms a verdict gets, for the same reason: a held `o` must be
1279
+ # one rerun, not a queue of them.
1280
+ cooldown_until = time.time() + VERDICT_COOLDOWN
1281
+ _fresh, seen = _changed(paths, seen)
1282
+ elif key in ("o", "O") and cooling and mode == MODE_BOARD:
1283
+ status = RERUN_COOLDOWN_MSG
879
1284
  elif key in VERDICTS and cooling:
880
1285
  # the key WAS a verdict and it was refused: say why. A keypress that
881
1286
  # vanishes silently reads as a dropped input, and the next reflex is to
@@ -926,6 +1331,13 @@ def build_parser():
926
1331
  "set, `curate`'s own default applies). The person pressing the "
927
1332
  "key is the author of the judgement -- the TUI never invents a "
928
1333
  "name for it")
1334
+ parser.add_argument("--rerun-cmd", default=None,
1335
+ help="the shell command `o` reruns, in the first configured repo's "
1336
+ "directory (e.g. \"pytest -q\"). The tool NEVER guesses it and "
1337
+ "never reads it from config (ADR-008/037): without this flag "
1338
+ "`o` is inert and says so. After the command, the engine's own "
1339
+ "`snapshot` ingests the report -- on a red run too, because a "
1340
+ "red measurement is still a measurement")
929
1341
  parser.add_argument("--cols", type=int, default=None)
930
1342
  parser.add_argument("--rows", type=int, default=None)
931
1343
  return parser
@@ -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.89.0",
4
+ "version": "1.91.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.89.0",
3
+ "version": "1.91.0",
4
4
  "description": "Uscha spec-driven development methodology for coding agents. Includes npm/npx router.",
5
5
  "author": {
6
6
  "name": "Andres Massello",