@andresmassello/uscha 1.90.0 → 1.92.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.
@@ -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
 
@@ -44,6 +56,11 @@ MIN_REFRESH = 0.5 # a poll faster than this is a busy loop, not a refresh
44
56
 
45
57
  MODE_BOARD = "board"
46
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"
47
64
  # VERDICTS geometry: title, rule, the pending line, the rule under the list, the rule under
48
65
  # the pane, the status line, the key hint. Everything else is queue rows + the detail pane.
49
66
  VERDICT_CHROME = 7
@@ -62,6 +79,18 @@ VERDICT_COOLDOWN = 0.25
62
79
  VERDICT_COOLDOWN_MSG = ("verdict recorded -- release the key (the queue advanced; the next "
63
80
  "observation is a new judgement)")
64
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
+
65
94
  # ANSI SGR by obligation state. TRACED and TAGGED deliberately share the UNMEASURED gray:
66
95
  # the v0.1 engine has no source for either rung (ADR-032), so they must read as "not
67
96
  # measured", never as PASS (INV-TOP-02, AC-T-08).
@@ -102,6 +131,17 @@ ACTIONS = {
102
131
  "TAGGED": "machine: run the case",
103
132
  }
104
133
 
134
+ # INV-TOP-06 (ADR-038): what a row that is green ON PAPER is actually waiting on when the
135
+ # seal is broken. Presentation, like ACTIONS above: the engine says WHAT broke (the reason),
136
+ # this says what the reader does about it, and the mapping is by reason prefix so a new
137
+ # reason class degrades to the generic line instead of to silence.
138
+ SEAL_ACTIONS = (
139
+ ("stale seal", "seal: snapshot at HEAD"),
140
+ ("repo subtree dirty", "seal: commit or discard, then snapshot"),
141
+ ("evidence altered", "seal: re-run the suite, then snapshot"),
142
+ ("evidence missing", "seal: re-run the suite, then snapshot"),
143
+ )
144
+
105
145
 
106
146
  # --------------------------------------------------------------------------- #
107
147
  # pure rendering #
@@ -151,6 +191,11 @@ def _pad(text, width):
151
191
  return str(text) + " " * max(0, width - _dw(text))
152
192
 
153
193
 
194
+ def _rjust(text, width):
195
+ """`str.rjust` measured in columns -- `_pad`'s mirror, for the numeric columns."""
196
+ return " " * max(0, width - _dw(text)) + str(text)
197
+
198
+
154
199
  def _fit(text, cols):
155
200
  """One line, never wider than the terminal. Wrapping would break the frame's row
156
201
  accounting, so an over-long line is cut and marked.
@@ -182,7 +227,15 @@ def _pct_line(terminado):
182
227
  """INV-TOP-01: the DONE bar carries an explicit `N unmeasured` suffix whenever anything
183
228
  is unmeasured, and the engine has already capped the percentage below 100 while any
184
229
  obligation sits outside MEASURED_PASS -- the renderer republishes that fact, it never
185
- recomputes it (AC-T-01, AC-T-04, AC-T-23)."""
230
+ recomputes it (AC-T-01, AC-T-04, AC-T-23).
231
+
232
+ INV-TOP-06 (ADR-038) rides on the same line: when the engine's seal is MEASURED broken
233
+ (`terminado.sealed.ok is False`) the bar says so and names the first reason -- and the
234
+ percentage beside it is already capped below 100, in the engine, for the same reason the
235
+ unmeasured cap is (single derivation, AC-T-24). An UNMEASURED seal (`ok is null`: no git
236
+ work tree, the state of every frozen fixture) adds NOTHING here: the seal is shown only
237
+ when it is measured, and decorating a header with the absence of a measurement would
238
+ turn INV-TOP-05's `—` into noise on every board."""
186
239
  done = terminado.get("done")
187
240
  total = terminado.get("total")
188
241
  pct = terminado.get("pct")
@@ -190,9 +243,37 @@ def _pct_line(terminado):
190
243
  line = "DONE %s/%s (%s%%)" % (_num(done), _num(total), _num(pct))
191
244
  if unm:
192
245
  line += " %s %d unmeasured" % (MID, unm)
246
+ # the state is a FILE a human can hand us (`--state`), so `sealed` is guarded by TYPE and
247
+ # not merely by truthiness: a string there would answer `.get` with an AttributeError, and a
248
+ # `reasons` that is a string is iterable -- the frame would name its first CHARACTER as the
249
+ # reason. Same guards `_top_spec_diff` applies on the engine side, for the same reason.
250
+ seal = terminado.get("sealed")
251
+ seal = seal if isinstance(seal, dict) else {}
252
+ if seal.get("ok") is False:
253
+ raw = seal.get("reasons")
254
+ reasons = [r for r in raw if isinstance(r, str) and r] if isinstance(raw, list) else []
255
+ line += " %s unsealed (%s)" % (MID, _safe(reasons[0]) if reasons
256
+ else "no reason recorded")
193
257
  return line
194
258
 
195
259
 
260
+ def _seal_action(sealed):
261
+ """The ACTION cell of a row that is green on paper while the seal is broken. Empty
262
+ whenever the seal is not MEASURED broken -- an unmeasured seal changes no row, and a
263
+ `sealed` of the wrong TYPE reads as no seal at all rather than raising mid-frame."""
264
+ seal = sealed if isinstance(sealed, dict) else {}
265
+ if seal.get("ok") is not False:
266
+ return ""
267
+ raw = seal.get("reasons")
268
+ for reason in (raw if isinstance(raw, list) else []):
269
+ if not isinstance(reason, str):
270
+ continue
271
+ for prefix, action in SEAL_ACTIONS:
272
+ if reason.startswith(prefix):
273
+ return action
274
+ return "seal: re-snapshot the current state"
275
+
276
+
196
277
  def _burnup_line(burnup, cols):
197
278
  """The score trend, labelled as a score trend. v0.1 has no obligation-count history
198
279
  (ADR-035/2), so calling this a burn-up of closed obligations would be a lie the label
@@ -230,15 +311,21 @@ def _cases_text(ob):
230
311
  return "%s/%s" % (_num(ob.get("cases_pass")), total)
231
312
 
232
313
 
233
- def _row(ob, selected):
314
+ def _row(ob, selected, seal_action=""):
234
315
  # the three left columns are cut and padded in COLUMNS: an id or state carrying wide
235
316
  # characters used to eat its neighbour's field and walk every column after it.
236
317
  gutter = "> " if selected else " "
318
+ action = ACTIONS.get(ob.get("state"), DASH)
319
+ # INV-TOP-06: only the rows that CLAIM to be done change, and only while the seal is
320
+ # measured broken. A failing or unmeasured row already names its own debtor; telling it
321
+ # about the seal too would bury the thing it is actually waiting for.
322
+ if seal_action and ob.get("state") == "MEASURED_PASS":
323
+ action = seal_action
237
324
  return "%s%s%s%s%7s%5s %s" % (
238
325
  gutter, _pad(_cut(_safe(ob.get("id") or "?"), 8), 8),
239
326
  _pad(_cut(_safe(ob.get("gate") or DASH), 8), 9),
240
327
  _pad(_cut(_safe(ob.get("state") or "?"), 14), 15), _cases_text(ob),
241
- _num(ob.get("age_hours")), ACTIONS.get(ob.get("state"), DASH))
328
+ _num(ob.get("age_hours")), action)
242
329
 
243
330
 
244
331
  def _safe(text):
@@ -294,6 +381,8 @@ def render(state, size, sel=0, plain=True, mode=MODE_BOARD, status=""):
294
381
  """
295
382
  if mode == MODE_VERDICTS:
296
383
  return _render_verdicts(state, size, sel, plain, status)
384
+ if mode == MODE_DIFF:
385
+ return _render_diff(state, size, sel, plain, status)
297
386
  return _render_board(state, size, sel, plain, status)
298
387
 
299
388
 
@@ -346,8 +435,9 @@ def _render_board(state, size, sel, plain, status=""):
346
435
  top = max(0, top)
347
436
 
348
437
  table = []
438
+ seal_action = _seal_action(terminado.get("sealed"))
349
439
  for i, ob in enumerate(obligations[top:top + body], start=top):
350
- line = _fit(_row(ob, i == sel), cols)
440
+ line = _fit(_row(ob, i == sel, seal_action), cols)
351
441
  table.append(line if plain else _colorize(line, ob.get("state")))
352
442
  hidden = len(obligations) - len(table)
353
443
  if hidden > 0:
@@ -386,11 +476,12 @@ def _render_board(state, size, sel, plain, status=""):
386
476
  out[-1] = _fit("status %s %s" % (MID, _safe(status)), cols)
387
477
  for i in range(feed_n):
388
478
  out.append(_feed_line(shown[i], cols, plain) if i < len(shown) else "")
389
- # `[v] verdicts` lost its `(M3)` marker in 1.89.0 because the key now works; `[d]/[o]`
390
- # keeps its `phase 2` marker because those two still do nothing (SPEC s1/s6). A hint that
391
- # labels a live key as future is the same class of stale claim the frames exist to catch.
392
- out.append("[j/k] move %s [r] reload %s [q] quit %s [v] verdicts %s "
393
- "[d]/[o] phase 2" % (MID, MID, MID, MID))
479
+ # every key on this line WORKS as of 1.91.0: `[v]` lost its `(M3)` marker when verdicts
480
+ # shipped, and `[d]/[o]` lose their `phase 2` marker here for the same reason. A hint that
481
+ # labels a live key as future is the same class of stale claim the frames exist to catch --
482
+ # and one that labels a dead key as live is the worse half of it.
483
+ out.append("[j/k] move %s [r] reload %s [q] quit %s [v] verdicts %s [d]iff %s [o] rerun"
484
+ % (MID, MID, MID, MID, MID))
394
485
  # a coloured line was already fitted BEFORE its escape bytes went in (table rows and
395
486
  # feed lines both), and re-fitting it here would count those bytes as visible width --
396
487
  # cutting the coloured frame ~9 characters shorter than the plain one it is supposed to
@@ -552,6 +643,150 @@ def _render_verdicts(state, size, sel, plain, status):
552
643
  return out[:rows] + [""] * max(0, rows - len(out))
553
644
 
554
645
 
646
+ # --------------------------------------------------------------------------- #
647
+ # DIFF mode -- spec <-> code drift, read-only (ADR-037 phase 2) #
648
+ # --------------------------------------------------------------------------- #
649
+ def first_repo(state):
650
+ """The repo this session acts on: the FIRST configured one, which is the same repo
651
+ `spec_pin` already labels the board with (ADR-032). Returns (name, path) or (None, None).
652
+
653
+ The choice is the engine's, not the TUI's -- `repos[]` arrives in configuration order and
654
+ this only takes the head of it. A multi-repo project therefore reruns and ingests ONE
655
+ repo, the first, and every line that depends on the choice says which repo it picked
656
+ rather than leaving the reader to assume it was all of them."""
657
+ repos = [r for r in (state.get("repos") or []) if isinstance(r, dict) and r.get("name")]
658
+ if not repos:
659
+ return None, None
660
+ return _safe(repos[0]["name"]), _safe(repos[0].get("path") or ".")
661
+
662
+
663
+ def _lag_text(value):
664
+ """A lag in days, or the em dash when the record carries none. `%g` so a whole number of
665
+ days reads as `60` and a fractional one keeps its tenth -- the record rounds to one
666
+ decimal and this neither adds precision nor drops it."""
667
+ if not isinstance(value, (int, float)):
668
+ return DASH
669
+ return "%g" % value
670
+
671
+
672
+ def _cut_tail(text, width):
673
+ """The longest SUFFIX of `text` that fits in `width` columns, whole characters only.
674
+ `_cut` read backwards -- same no-split-a-wide-glyph rule, same column arithmetic."""
675
+ if width <= 0:
676
+ return ""
677
+ out, used = [], 0
678
+ for ch in reversed(str(text)):
679
+ w = 0 if unicodedata.combining(ch) else (
680
+ 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1)
681
+ if used + w > width:
682
+ break
683
+ out.append(ch)
684
+ used += w
685
+ return "".join(reversed(out))
686
+
687
+
688
+ def _fit_tail(text, cols):
689
+ """Like `_fit`, but it keeps the END of the string. Used only for paths: a governed file
690
+ cut at the front still shows the file that moved, cut at the back it shows a directory.
691
+
692
+ Measured and cut in COLUMNS (`_dw`/`_cut_tail`), like `_fit`: `len()` and slicing count
693
+ codepoints, and a CJK path component draws two columns per codepoint -- the DIFF pane was
694
+ the one surface still measuring itself in codepoints after 1.90.0 fixed the board. As
695
+ with `_fit`, the cut may land one column short rather than exactly on `cols - 1` when the
696
+ character at the boundary is wide: one column narrow keeps the frame, one column wide
697
+ does not."""
698
+ text = _safe(text)
699
+ if cols <= 0:
700
+ return ""
701
+ if _dw(text) <= cols:
702
+ return text
703
+ return "…" + _cut_tail(text, cols - 1) if cols > 1 else _cut_tail(text, cols)
704
+
705
+
706
+ def _diff_widths(cols):
707
+ """` <doc> <lag> <code ref>` -- 2 for the gutter, 2+2 for the separators, 6 for LAG."""
708
+ lag = 6
709
+ code = max(10, (cols - 4 - lag - 2) // 2)
710
+ doc = max(10, cols - 4 - lag - 2 - code)
711
+ return doc, lag, code
712
+
713
+
714
+ def _diff_head(diff, state):
715
+ """The one line that says WHEN this was measured, or that nobody measured it.
716
+
717
+ The honest empty case is the whole point of the pane: with no `spec-drift` record the
718
+ board must not read as "no drift" -- it says there is no run and names the command that
719
+ would produce one (INV-TOP-05, the same rule the feed's empty label follows)."""
720
+ if not diff:
721
+ repo, _path = first_repo(state)
722
+ return ("no spec-drift run recorded -- run `qa_ledger.py spec-drift --repo %s`"
723
+ % (repo or "<repo>"))
724
+ stale = [s for s in (diff.get("stale") or []) if isinstance(s, dict)]
725
+ lag = diff.get("max_lag_days")
726
+ # the COUNT sits before the timestamp on purpose: at the 80-column floor this line is the
727
+ # one that gets cut, and "2 of 4 stale" is the fact the reader came for.
728
+ return ("spec %s code drift %s advisory %s %d of %d doc(s) stale (lag > %s d) %s "
729
+ "measured %s" % ("↔", MID, MID, len(stale), diff.get("docs_total") or 0,
730
+ _num(lag), MID, _safe(diff.get("measured_at")) or DASH))
731
+
732
+
733
+ def _render_diff(state, size, sel, plain, status):
734
+ """The spec↔code drift pane: `spec_diff` drawn, nothing derived and nothing run.
735
+
736
+ Read-only twice over. `d` never invokes `spec-drift` (that command walks git and WRITES
737
+ its latest-state record; this pane is a projection of that record, ADR-037), and `render`
738
+ performs no I/O at all (ADR-034). What is on screen is what the last real run measured,
739
+ with its own timestamp beside it so an old measurement cannot pass for a fresh one.
740
+
741
+ `sel` and `plain` are accepted and unused: this pane has no cursor (v1 shows the worst
742
+ lags first and NAMES the shortfall rather than scrolling) and no colour of its own -- a
743
+ green/red here would read as a gate, and ADR-005 drift never gates."""
744
+ cols, rows = size
745
+ cols = max(20, int(cols))
746
+ rows = max(DIFF_CHROME + 1, int(rows))
747
+ diff = state.get("spec_diff") if isinstance(state.get("spec_diff"), dict) else None
748
+ doc_w, lag_w, code_w = _diff_widths(cols)
749
+
750
+ out = [_spread("uscha top %s %s %s spec drift"
751
+ % (MID, _safe(state.get("project")) or "(unnamed project)", MID),
752
+ "step #%s" % _safe(_num(state.get("step"))), cols),
753
+ RULE * cols,
754
+ _fit(_diff_head(diff, state), cols),
755
+ RULE * cols,
756
+ " %s %s %s" % (_pad("DOC", doc_w), _rjust("LAG/d", lag_w),
757
+ "A NEWER GOVERNED FILE")]
758
+
759
+ body = []
760
+ stale = [s for s in ((diff or {}).get("stale") or []) if isinstance(s, dict)]
761
+ for s in stale:
762
+ ref = _fit_tail(s.get("code_ref") or DASH, code_w)
763
+ more = s.get("newer_files_total")
764
+ if isinstance(more, int) and more > 1:
765
+ # the row shows ONE file of the N that outran the doc, and says so: a single
766
+ # path with no cardinality beside it reads as "one file changed".
767
+ ref = _fit(ref + " (1 of %d)" % more, code_w)
768
+ body.append(" %s %s %s" % (_pad(_fit(_safe(s.get("doc")) or "?", doc_w), doc_w),
769
+ _rjust(_lag_text(s.get("lag_days")), lag_w), ref))
770
+ if not diff:
771
+ body = [" nothing to show until a spec-drift run is recorded %s `d` reads that "
772
+ "record, it never runs it" % MID]
773
+ elif not stale:
774
+ body = [" no spec document is stale at this lag %s every one reads CLEAN, unmapped "
775
+ "or untracked" % MID]
776
+
777
+ avail = rows - DIFF_CHROME
778
+ if len(body) > avail:
779
+ body = body[:max(0, avail - 1)] + [" %s %d more stale doc(s) do not fit at this size "
780
+ "(worst lag first)" % (DASH, len(body) - avail + 1)]
781
+ out.extend(body[:avail])
782
+ out.extend([""] * max(0, avail - len(body)))
783
+ out.append(RULE * cols)
784
+ out.append(_fit("status %s %s" % (MID, _safe(status) or DIFF_HINT), cols))
785
+ out.append(_fit("[t]/[Esc] back %s [r] reload %s [q] quit" % (MID, MID), cols))
786
+ out = [line if "\x1b" in line else _fit(line, cols) for line in out]
787
+ return out[:rows] + [""] * max(0, rows - len(out))
788
+
789
+
555
790
  # --------------------------------------------------------------------------- #
556
791
  # state loading (the ONE read boundary -- it shells out, it never re-derives) #
557
792
  # --------------------------------------------------------------------------- #
@@ -795,6 +1030,85 @@ def apply_verdict(ob, verdict, args, engine=None):
795
1030
  return False, said or ("curate exited %s -- nothing was recorded" % rc)
796
1031
 
797
1032
 
1033
+ def _rerun_call(cmd, cwd):
1034
+ """The human's OWN command, run in the tracked repo's directory (ADR-037, option B).
1035
+
1036
+ `shell=True` is the decision, not an oversight: what arrives here is the shell string the
1037
+ human typed after `--rerun-cmd` (`pytest -q && npm test`), exactly the way `cleanroom
1038
+ --run` and `golden-coverage --harness` take theirs -- the engine never decides what to
1039
+ run, and neither does this TUI (ADR-008). The trust boundary is the human's own shell,
1040
+ which ADR-037 states rather than pretends to mitigate: a misspelt flag runs whatever the
1041
+ shell makes of it, in that directory, the same as typing it there.
1042
+
1043
+ Output is NOT captured: a test suite writes to the terminal the human is watching, and
1044
+ swallowing it would replace measured output with a spinner. One function on purpose --
1045
+ it is the boundary the suite replaces to assert the command and the ONE call per keypress
1046
+ without running anything (AC-T-26). Returns the exit code."""
1047
+ return subprocess.run(cmd, shell=True, cwd=cwd).returncode
1048
+
1049
+
1050
+ def _snapshot_call(engine, ledger, repo):
1051
+ """The INGEST, made by the engine's own `snapshot` -- the same subcommand the dev loop
1052
+ runs at every pass close. This is what makes `o` honest: the board moves on ingested
1053
+ evidence or it does not move at all, and the TUI still builds no record of its own
1054
+ (ADR-033's rule, one more engine subcommand under it -- ADR-037).
1055
+
1056
+ Returns (returncode, the engine's own last line)."""
1057
+ argv = [sys.executable, engine, "snapshot", "--ledger", ledger, "--repo", repo]
1058
+ proc = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1059
+ said = ((proc.stderr or b"").decode("utf-8", "replace").strip().splitlines()
1060
+ or (proc.stdout or b"").decode("utf-8", "replace").strip().splitlines())
1061
+ return proc.returncode, (said[-1] if said else "")
1062
+
1063
+
1064
+ def rerun_banner(cmd, repo):
1065
+ """What the board says WHILE the command runs. Pure, so the frame that carries it stays
1066
+ a pure function of its inputs; the loop draws it before it blocks."""
1067
+ return ("rerun: %s %s running in %s (first configured repo) %s verdict keys locked"
1068
+ % (_safe(cmd), MID, _safe(repo), MID))
1069
+
1070
+
1071
+ def run_rerun(state, args, engine=None):
1072
+ """One keypress -> the human's command, then the engine's ingest, then a reload upstream.
1073
+ Returns (ran?, the line the status bar shows).
1074
+
1075
+ The order is the decision: the snapshot runs **whether or not the command exited 0**. A
1076
+ red suite is evidence too, and the whole point of `o` is that what lands on the board is
1077
+ what a report says, never what an exit code narrated -- refusing to ingest a red run
1078
+ would leave the board showing the previous, greener measurement (measured red beats
1079
+ narrated green, kit 1.48.1). What a non-zero exit changes is the STATUS LINE, which names
1080
+ it, and nothing else.
1081
+
1082
+ Three refusals come first, none of which spawns anything: no `--rerun-cmd` (the command
1083
+ is the human's to supply, ADR-008), a `--state` frozen snapshot (the ledger on disk is
1084
+ not the one on screen -- the same refusal a verdict gets), and a ledger with no configured
1085
+ repo (there is no cwd to run in and no repo to ingest for)."""
1086
+ cmd = getattr(args, "rerun_cmd", None)
1087
+ if not cmd:
1088
+ return False, RERUN_MISSING_MSG
1089
+ if getattr(args, "state", None):
1090
+ return False, RERUN_FROZEN_MSG
1091
+ repo, path = first_repo(state)
1092
+ if not repo:
1093
+ return False, RERUN_NO_REPO_MSG
1094
+ eng = engine or engine_path()
1095
+ if not eng:
1096
+ return False, "qa_ledger.py not found next to uscha_top.py"
1097
+ cwd = os.path.join(os.path.dirname(os.path.realpath(args.ledger)) or ".", path or ".")
1098
+ if not os.path.isdir(cwd):
1099
+ return False, "repo path '%s' does not exist -- nothing was run" % path
1100
+ code = _rerun_call(cmd, cwd)
1101
+ rc, said = _snapshot_call(eng, args.ledger, repo)
1102
+ tail = said or ("snapshot exited %s" % rc)
1103
+ if rc != 0:
1104
+ return True, ("rerun exit %s %s snapshot FAILED (%s) -- nothing was ingested"
1105
+ % (code, MID, tail))
1106
+ if code != 0:
1107
+ # a red run is still a measurement: it is ingested, and the line says both facts.
1108
+ return True, "rerun exit %s (red) %s ingested: %s" % (code, MID, tail)
1109
+ return True, "rerun exit 0 %s ingested: %s" % (MID, tail)
1110
+
1111
+
798
1112
  def after_verdict(sel, count):
799
1113
  """Where the cursor lands once the queue has been re-read: (selection, mode).
800
1114
 
@@ -808,7 +1122,26 @@ def after_verdict(sel, count):
808
1122
  return max(0, min(sel, count - 1)), MODE_VERDICTS
809
1123
 
810
1124
 
811
- def dispatch_mode(key, mode, sel, count, cooling=False):
1125
+ def is_rerun_key(key, mode, cooling=False, rerunning=False):
1126
+ """Is this keypress a rerun request (ADR-037)? A pure predicate, and deliberately NOT a
1127
+ sixth member of `dispatch_mode`'s tuple: that shape is what M3 measured, and widening a
1128
+ measured contract so it can carry a second action is how a keymap grows a second write
1129
+ path nobody counted. The caller spends a True on exactly one `_rerun_call` + one
1130
+ `_snapshot_call`, never a loop (AC-T-29).
1131
+
1132
+ `o` answers on the BOARD only -- the verdicts queue and the drift pane have their own
1133
+ jobs -- and it is refused while a rerun is in flight or while the 250 ms cooldown after
1134
+ one is still running, so a HELD `o` is one rerun and not a queue of them (the same guard
1135
+ a held verdict key gets, ADR-033)."""
1136
+ return key in ("o", "O") and mode == MODE_BOARD and not cooling and not rerunning
1137
+
1138
+
1139
+ # `rerunning=True` is never passed by `_loop`, and that is not an oversight: the rerun is
1140
+ # SYNCHRONOUS (the spawn blocks the loop, and `drain_keys` throws away whatever was typed
1141
+ # meanwhile), so the sync block plus the drain IS the lock -- the flag would have nothing to
1142
+ # guard against. It exists as a MEASURED contract: the predicate is what a future async rerun
1143
+ # would have to honour, and AC-T-27 pins it as a pure function rather than racing a terminal.
1144
+ def dispatch_mode(key, mode, sel, count, cooling=False, rerunning=False):
812
1145
  """The mode machine: key + current mode -> (mode, selection, quit?, reload?, verdict).
813
1146
 
814
1147
  Pure, and the ONE place a keypress becomes a write decision -- `verdict` is a string the
@@ -821,9 +1154,21 @@ def dispatch_mode(key, mode, sel, count, cooling=False):
821
1154
  stays pure). While it is true, `p`/`f`/`u` produce NO verdict: a key held down repeats,
822
1155
  and the second repeat would judge the observation that just took the cursor's place. Every
823
1156
  other key keeps working -- the cooldown blocks writes, not the reader."""
1157
+ if mode == MODE_DIFF:
1158
+ # a read-only pane with a read-only keymap: leave, re-read, or quit. No cursor (the
1159
+ # pane names what does not fit instead of scrolling) and no write of any kind.
1160
+ if key in ("q", "Q", "\x03"):
1161
+ return mode, sel, True, False, None
1162
+ if key in ("t", "T", "\x1b", "d", "D"):
1163
+ return MODE_BOARD, 0, False, False, None
1164
+ if key == "r":
1165
+ return mode, sel, False, True, None
1166
+ return mode, sel, False, False, None
824
1167
  if mode != MODE_VERDICTS:
825
1168
  if key in ("v", "V"):
826
1169
  return MODE_VERDICTS, 0, False, False, None
1170
+ if key in ("d", "D"):
1171
+ return MODE_DIFF, 0, False, False, None
827
1172
  sel, quit_now, reload_now = dispatch(key, sel, count)
828
1173
  return MODE_BOARD, sel, quit_now, reload_now, None
829
1174
  if key in ("q", "Q", "\x03"):
@@ -840,7 +1185,11 @@ def dispatch_mode(key, mode, sel, count, cooling=False):
840
1185
  # an empty queue produces NO verdict: there is nothing selected to judge, and a
841
1186
  # keypress that writes anyway would be a verdict the human never aimed at an OBS.
842
1187
  # Neither does a queue still cooling from the last one.
843
- return mode, sel, False, False, (VERDICTS[key] if (count and not cooling) else None)
1188
+ # `rerunning` is the same refusal for a different reason (ADR-037): while a rerun is
1189
+ # in flight the queue on screen was read BEFORE it, and a verdict recorded against a
1190
+ # queue the ingest is about to move is a judgement aimed at the wrong observation.
1191
+ return mode, sel, False, False, (VERDICTS[key] if (count and not cooling
1192
+ and not rerunning) else None)
844
1193
  if len(str(key)) == 1 and key in "123456789":
845
1194
  n = int(key) - 1
846
1195
  return mode, (n if n < count else sel), False, False, None
@@ -904,6 +1253,25 @@ def _apply_and_advance(state, args, queue, cur, verdict):
904
1253
  return state, cur, mode, status, True
905
1254
 
906
1255
 
1256
+ def _rerun_and_reload(state, args):
1257
+ """ONE keypress -> ONE command -> ONE `snapshot` -> re-read. Returns (state, status).
1258
+
1259
+ A function of its own for the same structural reason `_apply_and_advance` is one, and the
1260
+ suite asserts it the same way (AC-T-29): the module's single call to `run_rerun` must have
1261
+ no `for`/`while` above it, so no later edit can quietly turn one keypress into a pass over
1262
+ the repos. The re-read afterwards is what makes the new measurement visible; DONE moves
1263
+ here or nowhere, because the ingest is the only thing that can move it (INV-TOP-03).
1264
+
1265
+ The input buffer is drained whether anything ran or not: a suite that takes a minute is
1266
+ exactly when a human types, and those keystrokes belong to the terminal they were typed
1267
+ into, not to the board that comes back."""
1268
+ ran, status = run_rerun(state, args)
1269
+ drain_keys()
1270
+ if not ran:
1271
+ return state, status
1272
+ return _reload(state, args), status
1273
+
1274
+
907
1275
  def _loop(state, args):
908
1276
  sel = 0 # the board's cursor
909
1277
  vsel = 0 # the verdict queue's cursor, kept apart from it
@@ -947,6 +1315,26 @@ def _loop(state, args):
947
1315
  cooldown_until = time.time() + VERDICT_COOLDOWN
948
1316
  if wrote:
949
1317
  _fresh, seen = _changed(paths, seen)
1318
+ elif is_rerun_key(key, mode, cooling=cooling):
1319
+ if getattr(args, "rerun_cmd", None):
1320
+ # the frame the human watches WHILE the command runs, drawn before
1321
+ # the spawn because the spawn blocks this loop until it returns.
1322
+ # That synchronous shape is also why the verdict lock is measured on
1323
+ # `dispatch_mode(..., rerunning=True)` and not raced against a
1324
+ # terminal (AC-T-27): while the suite runs, no key is read at all --
1325
+ # what is typed lands in the buffer and the drain throws it away.
1326
+ sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(render(
1327
+ state, terminal_size(args.cols, args.rows), sel=sel, plain=False,
1328
+ mode=mode, status=rerun_banner(args.rerun_cmd,
1329
+ first_repo(state)[0] or "?"))))
1330
+ sys.stdout.flush()
1331
+ state, status = _rerun_and_reload(state, args)
1332
+ # the same 250 ms a verdict gets, for the same reason: a held `o` must be
1333
+ # one rerun, not a queue of them.
1334
+ cooldown_until = time.time() + VERDICT_COOLDOWN
1335
+ _fresh, seen = _changed(paths, seen)
1336
+ elif key in ("o", "O") and cooling and mode == MODE_BOARD:
1337
+ status = RERUN_COOLDOWN_MSG
950
1338
  elif key in VERDICTS and cooling:
951
1339
  # the key WAS a verdict and it was refused: say why. A keypress that
952
1340
  # vanishes silently reads as a dropped input, and the next reflex is to
@@ -997,6 +1385,13 @@ def build_parser():
997
1385
  "set, `curate`'s own default applies). The person pressing the "
998
1386
  "key is the author of the judgement -- the TUI never invents a "
999
1387
  "name for it")
1388
+ parser.add_argument("--rerun-cmd", default=None,
1389
+ help="the shell command `o` reruns, in the first configured repo's "
1390
+ "directory (e.g. \"pytest -q\"). The tool NEVER guesses it and "
1391
+ "never reads it from config (ADR-008/037): without this flag "
1392
+ "`o` is inert and says so. After the command, the engine's own "
1393
+ "`snapshot` ingests the report -- on a red run too, because a "
1394
+ "red measurement is still a measurement")
1000
1395
  parser.add_argument("--cols", type=int, default=None)
1001
1396
  parser.add_argument("--rows", type=int, default=None)
1002
1397
  return parser
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "uscha",
4
- "version": "1.90.0",
4
+ "version": "1.92.0",
5
5
  "displayName": "Uscha",
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.",
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, 53 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
7
7
  "author": {
8
8
  "name": "Andres Massello",
9
9
  "url": "https://github.com/andresmassello"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uscha",
3
- "version": "1.90.0",
3
+ "version": "1.92.0",
4
4
  "description": "Uscha spec-driven development methodology for coding agents. Includes npm/npx router.",
5
5
  "author": {
6
6
  "name": "Andres Massello",
@@ -1,6 +1,6 @@
1
1
  # uscha-kit
2
2
 
3
- **Kit version:** v1.90.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
3
+ **Kit version:** v1.92.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`,
@@ -591,11 +591,7 @@ python3 $QL simplicity-check --diff changes.diff --json # consumed by usc
591
591
 
592
592
  ## Ledger subcommands
593
593
 
594
- `doctor - rubric-ingest - init - snapshot - check-coverage - log-step - ingest-gate - phase -
595
- converged - oscillation - escalate - resolve-escalation - log-gate - flag-blocker -
596
- production-finding - spec-doubt - spec-change-request - regression-check - summary - readiness -
597
- execution-policy - dashboard - rebuild - simplicity-check - waste-check - pit-check - gate-check -
598
- spec-check - golden-diff` - the exact current `qa_ledger.py` parser surface; each supports `--help`.
594
+ `bench - bench-curate - bench-r2 - bench-roundtrip - bootstrap-oracle - bootstrap-variance - check-coverage - check-terminado - cleanroom - compile-ingest - compile-validate - converged - curate - curation-check - dashboard - discover - doctor - escalate - execution-policy - facts - fastpath-eval - fidelity - flag-blocker - gate-check - golden-coverage - golden-diff - ingest-gate - init - ir-extract - ir-render - lang-compare - log-gate - log-step - oscillation - phase - pit-check - production-finding - promote - readiness - rebuild - regression-check - resolve-escalation - roundtrip - rubric-ingest - simplicity-check - snapshot - spec-change-request - spec-check - spec-doubt - spec-drift - summary - top - waste-check` - the exact current `qa_ledger.py` parser surface (53 subcommands, derived from `SYSTEM-FACTS.json`, itself introspected from `build_parser()`); each supports `--help`.
599
595
 
600
596
  The **fact gates** (golden-diff, gate-check, pit-check, simplicity) are PERSISTED with
601
597
  `log-gate`: a fail blocks convergence and caps readiness ≤65 via the ledger. A CONSTITUTION
package/uscha-kit/VERSION CHANGED
@@ -1 +1 @@
1
- uscha-kit 1.90.0
1
+ uscha-kit 1.92.0
@@ -842,6 +842,10 @@ def cmd_top(args):
842
842
  # and uscha_top.py falls back to $USERNAME/$USER, then to curate's own default.
843
843
  if getattr(args, "human", None):
844
844
  cmd += ["--human", args.human]
845
+ # and so does the rerun command (1.91.0, ADR-037): `o` runs what the HUMAN passed at
846
+ # launch and nothing else -- the launcher forwards the string, it never supplies one.
847
+ if getattr(args, "rerun_cmd", None):
848
+ cmd += ["--rerun-cmd", args.rerun_cmd]
845
849
  rc = subprocess.call(cmd)
846
850
  if rc:
847
851
  raise SystemExit(rc)
@@ -1051,6 +1055,7 @@ def build_parser():
1051
1055
  top.add_argument("--once", action="store_true", help="print one plain frame and exit (implied without a TTY)")
1052
1056
  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
1057
  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)")
1058
+ top.add_argument("--rerun-cmd", default=None, help="the shell command `o` reruns in the first configured repo (e.g. \"pytest -q\"); never guessed and never read from config -- without it `o` is inert. The engine's own `snapshot` ingests the report afterwards, red runs included")
1054
1059
  top.add_argument("--json", action="store_true", help="print the engine's read-only `top --json` contract instead of rendering it")
1055
1060
  top.set_defaults(func=cmd_top)
1056
1061
  return parser
@@ -459,6 +459,18 @@ BLOCKER/CRITICAL + no open escalation), never self-declared — if it exits 1, t
459
459
  output lists exactly which facts are missing; do NOT open the PR, close the gap.
460
460
  A `spike/*` branch NEVER passes this gate (kit 1.19.0): spike code is disposable
461
461
  by contract — its only legitimate output is an ADR with lessons, never a merge.
462
+ - **Before declaring TERMINADO, run the seal (kit 1.92.0, INV-T1 / ADR-038):**
463
+
464
+ ```bash
465
+ python3 $QL check-terminado # 0 = sealed · 1 = broken · 2 = UNMEASURED
466
+ ```
467
+
468
+ It recomputes, from the ledger and the tree, whether the recorded evidence still belongs to
469
+ the code on disk: the repo subtree clean, `HEAD` equal to the last snapshot's commit, every
470
+ ingested report still hashing to what was recorded. **Exit 1** — do not declare TERMINADO:
471
+ re-snapshot on the CURRENT state (`snapshot --repo <REPO> --phase post`) and record why the
472
+ seal broke. **Exit 2** — the seal is UNMEASURED (no git, or no snapshot recorded): say so
473
+ plainly; an answer nobody could measure is not a TERMINADO either.
462
474
  - Ensure conventional-commit history is clean.
463
475
  - Open the PR(s). Confirm CI is green.
464
476
  - **STOP.** Present the PR link(s) and wait for the human to merge.