@andresmassello/uscha 1.85.1 → 1.86.1

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.
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ uscha_top.py — the terminal projection of the QA ledger (`uscha top`, ADR-031/034).
4
+
5
+ It DERIVES NOTHING. Every state, cardinality, median and percentage on screen is read from
6
+ one read-only engine call, `qa_ledger.py top --json` (ADR-032), and `render()` is a pure
7
+ function of that object: same JSON + same size -> byte-identical lines, no clock, no files,
8
+ no subprocess, no environment. That purity is what lets the golden frames under
9
+ tests/fixtures/uscha-top/golden/ be the oracle: a renderer that quietly shows 100% while a
10
+ criterion is unmeasured fails a snapshot, not a code review (ADR-034).
11
+
12
+ Truth-pass (INV-TOP-05): a field the engine emits as null renders as an em dash, never as a
13
+ zero and never as a guess. In v0.1 that is ETA, every AGE, drift, and the trace column --
14
+ each with its deferred wiring recorded in ADR-035.
15
+
16
+ M1 scope: the read-only BOARD. The live feed (M2) and VERDICTS mode (M3) are not wired; the
17
+ panes that will hold them are labelled as such rather than faked.
18
+
19
+ Stdlib only. Python 3.8+. Runnable directly or via `python -m uscha_top`.
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+
29
+ DEFAULT_LEDGER = "QA-LEDGER.json"
30
+ FALLBACK_SIZE = (100, 32)
31
+
32
+ # Lines the board always spends on chrome: the title, 3 rules, 4 KPI lines, the table
33
+ # header, the feed label and the key hint. Everything else is table rows + feed.
34
+ CHROME_LINES = 11
35
+ FEED_MAX = 3
36
+ BURNUP_MAX = 24
37
+
38
+ # ANSI SGR by obligation state. TRACED and TAGGED deliberately share the UNMEASURED gray:
39
+ # the v0.1 engine has no source for either rung (ADR-032), so they must read as "not
40
+ # measured", never as PASS (INV-TOP-02, AC-T-08).
41
+ PALETTE = {
42
+ "MEASURED_PASS": "32",
43
+ "MEASURED_FAIL": "31",
44
+ "QUARANTINE": "33",
45
+ "UNMEASURED": "90",
46
+ "TRACED": "90",
47
+ "TAGGED": "90",
48
+ }
49
+ RESET = "\x1b[0m"
50
+ DASH = "—" # the honest "no source" marker (INV-TOP-05)
51
+ MID = "·"
52
+ RULE = "─"
53
+ BLOCKS = "▁▂▃▄▅▆▇█"
54
+
55
+ # What the reader is expected to DO about a row. Presentation, not a KPI: no number here.
56
+ ACTIONS = {
57
+ "MEASURED_PASS": DASH,
58
+ "MEASURED_FAIL": "machine: fix, then rerun",
59
+ "QUARANTINE": "you: record a verdict",
60
+ "UNMEASURED": "you: tag a test",
61
+ "TRACED": "you: tag a test",
62
+ "TAGGED": "machine: run the case",
63
+ }
64
+
65
+
66
+ # --------------------------------------------------------------------------- #
67
+ # pure rendering #
68
+ # --------------------------------------------------------------------------- #
69
+ def _fit(text, cols):
70
+ """One line, never wider than the terminal. Wrapping would break the frame's row
71
+ accounting, so an over-long line is cut and marked."""
72
+ if cols <= 0:
73
+ return ""
74
+ if len(text) <= cols:
75
+ return text
76
+ return text[:cols - 1] + "…" if cols > 1 else text[:cols]
77
+
78
+
79
+ def _spread(left, right, cols):
80
+ """left ... right on one line, right-aligned, degrading to just `left` when tight."""
81
+ if len(left) + len(right) + 1 > cols:
82
+ return _fit(left, cols)
83
+ return left + " " * (cols - len(left) - len(right)) + right
84
+
85
+
86
+ def _num(value):
87
+ """A number the engine emitted, or the em dash when it emitted null."""
88
+ return DASH if value is None else str(value)
89
+
90
+
91
+ def _pct_line(terminado):
92
+ """INV-TOP-01: the DONE bar carries an explicit `N unmeasured` suffix whenever anything
93
+ is unmeasured, and the engine has already capped the percentage below 100 while any
94
+ obligation sits outside MEASURED_PASS -- the renderer republishes that fact, it never
95
+ recomputes it (AC-T-01, AC-T-04, AC-T-23)."""
96
+ done = terminado.get("done")
97
+ total = terminado.get("total")
98
+ pct = terminado.get("pct")
99
+ unm = terminado.get("unmeasured") or 0
100
+ line = "DONE %s/%s (%s%%)" % (_num(done), _num(total), _num(pct))
101
+ if unm:
102
+ line += " %s %d unmeasured" % (MID, unm)
103
+ return line
104
+
105
+
106
+ def _burnup_line(burnup, cols):
107
+ """The score trend, labelled as a score trend. v0.1 has no obligation-count history
108
+ (ADR-035/2), so calling this a burn-up of closed obligations would be a lie the label
109
+ exists to prevent."""
110
+ points = [p for p in (burnup or {}).get("weeks") or [] if isinstance(p, (int, float))]
111
+ label = "score trend "
112
+ note = " (readiness score, not closed obligations)"
113
+ if not points:
114
+ return label + DASH + " (no `readiness --record` history yet)"
115
+ room = max(4, min(BURNUP_MAX, cols - len(label) - len(note)))
116
+ bars = "".join(BLOCKS[min(len(BLOCKS) - 1, max(0, int(p) * len(BLOCKS) // 101))]
117
+ for p in points[-room:])
118
+ return label + bars + note
119
+
120
+
121
+ def _spec_pin_text(spec_pin):
122
+ """git HEAD, labelled for what it is. There is no pinned-spec concept in the engine yet
123
+ (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)."""
125
+ if not spec_pin or not spec_pin.get("sha"):
126
+ return "spec_pin " + DASH
127
+ mark = ("clean-room verified" if spec_pin.get("clean_room_verified")
128
+ else "not clean-room verified")
129
+ return "spec_pin %s (%s)" % (spec_pin["sha"], mark)
130
+
131
+
132
+ def _cases_text(ob):
133
+ total = ob.get("cases_total") or 0
134
+ if not total:
135
+ return DASH # no ingested case names this criterion: no count to show
136
+ return "%s/%s" % (_num(ob.get("cases_pass")), total)
137
+
138
+
139
+ def _row(ob, selected):
140
+ gutter = "> " if selected else " "
141
+ return "%s%-8s%-9s%-15s%7s%5s %s" % (
142
+ gutter, str(ob.get("id") or "?")[:8], str(ob.get("gate") or DASH)[:8],
143
+ str(ob.get("state") or "?")[:14], _cases_text(ob),
144
+ _num(ob.get("age_hours")), ACTIONS.get(ob.get("state"), DASH))
145
+
146
+
147
+ def _colorize(line, state):
148
+ code = PALETTE.get(state)
149
+ if not code or state not in line:
150
+ return line
151
+ return line.replace(state, "\x1b[%sm%s%s" % (code, state, RESET), 1)
152
+
153
+
154
+ def render(state, size, sel=0, plain=True):
155
+ """The whole board as a list of exactly `rows` lines, none wider than `cols`.
156
+
157
+ 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
+ plain=True emits no escape sequences at all -- the mode `--once`, CI and the golden
160
+ frames use, so a snapshot compares text and not terminal control codes.
161
+ """
162
+ cols, rows = size
163
+ cols = max(20, int(cols))
164
+ rows = max(CHROME_LINES + 1, int(rows))
165
+ obligations = state.get("obligations") or []
166
+ terminado = state.get("terminado") or {}
167
+ debtors = state.get("debtors") or {}
168
+ honesty = state.get("honesty") or {}
169
+
170
+ out = []
171
+ out.append(_spread("uscha top %s %s" % (MID, state.get("project") or "(unnamed project)"),
172
+ "step #%s" % _num(state.get("step")), cols))
173
+ out.append(RULE * cols)
174
+ out.append(_pct_line(terminado))
175
+ out.append("machine owes %s %s you owe %s %s untagged %s %s ETA %s"
176
+ % (_num(debtors.get("machine")), MID, _num(debtors.get("you")), MID,
177
+ _num(debtors.get("untagged")), MID, _num(state.get("eta_min"))))
178
+ # honesty travels BESIDE done on purpose (INV-TOP-04): a thin denominator has to be
179
+ # visible at the same glance as the number it flatters.
180
+ out.append("honesty %s/%s (%s%%) measured %s %s"
181
+ % (_num(honesty.get("measured")), _num(honesty.get("total")),
182
+ _num(honesty.get("pct")), MID, _spec_pin_text(state.get("spec_pin"))))
183
+ out.append(_burnup_line(state.get("burnup"), cols))
184
+ out.append(RULE * cols)
185
+ out.append(" %-8s%-9s%-15s%7s%5s %s"
186
+ % ("ID", "GATE", "STATE", "CASES", "AGE", "ACTION"))
187
+
188
+ # Budget: the table is served FIRST and the feed gets only what is left over, so a short
189
+ # terminal shortens the feed and never the board (AC-T-21).
190
+ avail = rows - CHROME_LINES
191
+ want = len(obligations) or 1
192
+ feed_n = min(FEED_MAX, max(0, avail - want))
193
+ table_n = max(1, min(want, avail - feed_n))
194
+ body = table_n - 1 if len(obligations) > table_n else table_n
195
+ body = max(1, body)
196
+ top = 0
197
+ if sel >= body:
198
+ top = min(sel - body + 1, max(0, len(obligations) - body))
199
+ top = max(0, top)
200
+
201
+ table = []
202
+ for i, ob in enumerate(obligations[top:top + body], start=top):
203
+ line = _fit(_row(ob, i == sel), cols)
204
+ table.append(line if plain else _colorize(line, ob.get("state")))
205
+ hidden = len(obligations) - len(table)
206
+ if hidden > 0:
207
+ table.append(_fit(" %s %d more obligation(s) not shown (j/k to move)"
208
+ % (DASH, hidden), cols))
209
+ if not obligations:
210
+ table.append(_fit(" no tagged criterion in the acceptance file "
211
+ "(nothing to measure yet)", cols))
212
+ out.extend(table[:max(1, table_n)])
213
+
214
+ pad = avail - len(table[:max(1, table_n)]) - feed_n
215
+ out.extend([""] * max(0, pad))
216
+ out.append(RULE * cols)
217
+ events = state.get("events_tail") or []
218
+ out.append("feed %s the live event tail is M2; nothing is invented here" % MID)
219
+ for i in range(feed_n):
220
+ if i < len(events):
221
+ ev = events[i]
222
+ out.append(_fit(" %s %s" % (ev.get("ts") or DASH, ev.get("text") or ""), cols))
223
+ else:
224
+ out.append("")
225
+ out.append("[j/k] move %s [r] reload %s [q] quit %s [v] verdicts (M3) %s "
226
+ "[d]/[o] phase 2" % (MID, MID, MID, MID))
227
+ out = [_fit(line, cols) for line in out]
228
+ # exactly `rows` lines: a frame that drifts in height is a frame no snapshot can pin
229
+ out = out[:rows] + [""] * max(0, rows - len(out))
230
+ return out
231
+
232
+
233
+ # --------------------------------------------------------------------------- #
234
+ # state loading (the ONE read boundary -- it shells out, it never re-derives) #
235
+ # --------------------------------------------------------------------------- #
236
+ def engine_path():
237
+ """qa_ledger.py inside this kit, in either skill-tree layout -- the same both-layouts
238
+ resolution `install-uscha.py` uses for the mirador renderer."""
239
+ here = os.path.dirname(os.path.realpath(__file__))
240
+ local = os.path.join(here, "qa_ledger.py")
241
+ if os.path.isfile(local):
242
+ return local
243
+ kit = os.path.realpath(os.path.join(here, "..", "..", ".."))
244
+ for rel in (("skills", "uscha-devloop", "qa_ledger.py"),
245
+ (".claude", "skills", "uscha-devloop", "qa_ledger.py")):
246
+ cand = os.path.join(kit, *rel)
247
+ if os.path.isfile(cand):
248
+ return cand
249
+ return None
250
+
251
+
252
+ def load_state(state_path=None, ledger=DEFAULT_LEDGER, engine=None):
253
+ """The board's state: either a FROZEN `top --json` object (a file -- what the golden
254
+ frames render from, no engine call) or one read-only engine call. Raises RuntimeError
255
+ with the engine's own message rather than inventing an empty board."""
256
+ if state_path:
257
+ with open(state_path, "r", encoding="utf-8") as fh:
258
+ return json.load(fh)
259
+ if not os.path.isfile(ledger):
260
+ raise RuntimeError("ledger '%s' not found here -- run the dev loop first, or pass "
261
+ "--ledger" % ledger)
262
+ eng = engine or engine_path()
263
+ if not eng:
264
+ raise RuntimeError("qa_ledger.py not found next to uscha_top.py")
265
+ proc = subprocess.run([sys.executable, eng, "top", "--json", "--ledger", ledger],
266
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
267
+ if proc.returncode != 0:
268
+ raise RuntimeError((proc.stderr or b"").decode("utf-8", "replace").strip()
269
+ or "qa_ledger.py top failed")
270
+ return json.loads(proc.stdout.decode("utf-8"))
271
+
272
+
273
+ # --------------------------------------------------------------------------- #
274
+ # terminal plumbing (mockable, and NOT what the golden frames test) #
275
+ # --------------------------------------------------------------------------- #
276
+ def enable_vt():
277
+ """Windows is first-class (ADR-031): modern conhost and Windows Terminal handle VT, and
278
+ legacy conhost needs ENABLE_VIRTUAL_TERMINAL_PROCESSING switched on through ctypes.
279
+ Returns False when it cannot be enabled -- the caller then prints a plain frame instead
280
+ of spraying raw escapes at a terminal that would show them literally (AC-T-22)."""
281
+ if os.name != "nt":
282
+ return True
283
+ try:
284
+ import ctypes
285
+ kernel32 = ctypes.windll.kernel32
286
+ handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
287
+ mode = ctypes.c_uint32()
288
+ if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
289
+ return False
290
+ return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
291
+ except Exception:
292
+ return False
293
+
294
+
295
+ def read_key():
296
+ """One keypress, per platform, detected at runtime. Isolated in one function so the
297
+ dispatch above it can be driven by a scripted sequence in tests -- the driver itself is
298
+ not what is under test (ADR-034)."""
299
+ if os.name == "nt":
300
+ import msvcrt
301
+ ch = msvcrt.getch()
302
+ if ch in (b"\x00", b"\xe0"): # arrow keys arrive as a 2-byte pair
303
+ return {b"H": "k", b"P": "j"}.get(msvcrt.getch(), "")
304
+ return ch.decode("utf-8", "replace")
305
+ import termios
306
+ import tty
307
+ fd = sys.stdin.fileno()
308
+ saved = termios.tcgetattr(fd)
309
+ try:
310
+ tty.setraw(fd)
311
+ return sys.stdin.read(1)
312
+ finally:
313
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
314
+
315
+
316
+ def dispatch(key, sel, count):
317
+ """Key -> (new selection, quit?, reload?). Pure, so the keymap is testable without a
318
+ terminal: the driver below is not what is under test, this dispatch is (ADR-034)."""
319
+ if key in ("q", "Q", "\x03", "\x1b"):
320
+ return sel, True, False
321
+ if key == "j":
322
+ return min(sel + 1, max(0, count - 1)), False, False
323
+ if key == "k":
324
+ return max(0, sel - 1), False, False
325
+ if key == "r":
326
+ return sel, False, True
327
+ return sel, False, False
328
+
329
+
330
+ def terminal_size(cols=None, rows=None):
331
+ if cols and rows:
332
+ return int(cols), int(rows)
333
+ size = shutil.get_terminal_size(FALLBACK_SIZE)
334
+ return int(cols or size.columns), int(rows or size.lines)
335
+
336
+
337
+ def _print_frame(lines):
338
+ sys.stdout.write("\n".join(lines) + "\n")
339
+ sys.stdout.flush()
340
+
341
+
342
+ def _loop(state, args):
343
+ sel = 0
344
+ sys.stdout.write("\x1b[?25l")
345
+ try:
346
+ while True:
347
+ frame = render(state, terminal_size(args.cols, args.rows), sel=sel, plain=False)
348
+ sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(frame))
349
+ sys.stdout.flush()
350
+ sel, quit_now, reload_now = dispatch(
351
+ read_key(), sel, len(state.get("obligations") or []))
352
+ if quit_now:
353
+ return 0
354
+ if reload_now:
355
+ state = load_state(args.state, args.ledger)
356
+ except KeyboardInterrupt:
357
+ return 0
358
+ finally:
359
+ sys.stdout.write("\x1b[?25h\x1b[0m\n")
360
+ sys.stdout.flush()
361
+
362
+
363
+ def build_parser():
364
+ parser = argparse.ArgumentParser(
365
+ prog="uscha top",
366
+ description="terminal projection of the QA ledger (read-only board, ADR-031/034)")
367
+ parser.add_argument("--ledger", default=DEFAULT_LEDGER,
368
+ help="ledger the engine reads (default: %s)" % DEFAULT_LEDGER)
369
+ parser.add_argument("--state", default=None,
370
+ help="render a FROZEN `top --json` file instead of calling the "
371
+ "engine (the golden-frame path)")
372
+ parser.add_argument("--once", action="store_true",
373
+ help="print one plain frame and exit (implied without a TTY)")
374
+ parser.add_argument("--plain", action="store_true",
375
+ help="never emit escape sequences")
376
+ parser.add_argument("--refresh", type=float, default=2.0,
377
+ help="reserved for the M2 timed poll; M1 re-reads on the `r` key")
378
+ parser.add_argument("--cols", type=int, default=None)
379
+ parser.add_argument("--rows", type=int, default=None)
380
+ return parser
381
+
382
+
383
+ def main(argv=None):
384
+ for stream in (sys.stdout, sys.stderr):
385
+ try:
386
+ stream.reconfigure(encoding="utf-8")
387
+ except Exception:
388
+ pass
389
+ args = build_parser().parse_args(argv)
390
+ try:
391
+ state = load_state(args.state, args.ledger)
392
+ except (OSError, ValueError, RuntimeError) as exc:
393
+ sys.stderr.write("[uscha top] %s\n" % exc)
394
+ return 1
395
+ size = terminal_size(args.cols, args.rows)
396
+ # no TTY (pipe, CI, redirect) behaves as --once, and legacy conhost that refuses VT
397
+ # degrades the same way rather than printing escapes nobody can read (AC-T-20/22).
398
+ if args.once or args.plain or not sys.stdout.isatty() or not enable_vt():
399
+ _print_frame(render(state, size, sel=0, plain=True))
400
+ return 0
401
+ return _loop(state, args)
402
+
403
+
404
+ if __name__ == "__main__":
405
+ sys.exit(main())
@@ -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.85.1",
4
+ "version": "1.86.1",
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, 51 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, 52 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.85.1",
3
+ "version": "1.86.1",
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.85.1 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
3
+ **Kit version:** v1.86.1 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
4
4
 
5
5
  Spec-driven orchestrator + multi-repo QA for Claude Code, with a deterministic ledger.
6
6
  **Nine skills** (`uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`,
package/uscha-kit/VERSION CHANGED
@@ -1 +1 @@
1
- uscha-kit 1.85.1
1
+ uscha-kit 1.86.1
@@ -758,10 +758,12 @@ def _open_best_effort(path):
758
758
  pass # the renderer already printed the absolute path
759
759
 
760
760
 
761
- def _mirador_render_path():
762
- """The mirador renderer inside this kit (either skill-tree layout)."""
763
- for rel in (("skills", "uscha-mirador", "mirador-render.py"),
764
- (".claude", "skills", "uscha-mirador", "mirador-render.py")):
761
+ def _kit_script_path(skill_dir, filename):
762
+ """A script inside this kit, in either skill-tree layout (`skills/<skill_dir>/` or
763
+ `.claude/skills/<skill_dir>/`) -- the one resolution every sibling script lookup in this
764
+ file shares, and the same both-layouts precedent `uscha_top.py::engine_path()` uses on
765
+ its own side of the lookup."""
766
+ for rel in (("skills", skill_dir, filename), (".claude", "skills", skill_dir, filename)):
765
767
  candidate = KIT_ROOT.joinpath(*rel)
766
768
  if candidate.is_file():
767
769
  return candidate
@@ -772,7 +774,7 @@ def cmd_mirador(args):
772
774
  """`uscha mirador` — one command to render + open the project's dashboard.
773
775
  No paths, no python: the renderer self-resolves its engine/template siblings, and the
774
776
  ledger defaults to the QA-LEDGER.json convention in the current directory."""
775
- render = _mirador_render_path()
777
+ render = _kit_script_path("uscha-mirador", "mirador-render.py")
776
778
  if render is None:
777
779
  print("[uscha mirador] mirador-render.py not found in the kit", file=sys.stderr)
778
780
  raise SystemExit(1)
@@ -807,6 +809,38 @@ def cmd_mirador(args):
807
809
  print("\n[uscha mirador] stopped")
808
810
 
809
811
 
812
+ def cmd_top(args):
813
+ """`uscha top` — the live terminal board of the project's ledger (ADR-031).
814
+ Wired exactly like `mirador`: resolve the sibling script inside the kit and exec it with
815
+ this interpreter. `--json` is a passthrough to the engine's own read-only subcommand, so
816
+ a script can consume the contract without going through the renderer at all."""
817
+ if not Path(args.ledger).is_file():
818
+ print("[uscha top] ledger '%s' not found here -- run the dev loop first, or pass "
819
+ "--ledger" % args.ledger, file=sys.stderr)
820
+ raise SystemExit(1)
821
+ if args.json:
822
+ engine = _kit_script_path("uscha-devloop", "qa_ledger.py")
823
+ if engine is None:
824
+ print("[uscha top] qa_ledger.py not found in the kit", file=sys.stderr)
825
+ raise SystemExit(1)
826
+ rc = subprocess.call([sys.executable, str(engine), "top", "--json",
827
+ "--ledger", args.ledger])
828
+ if rc:
829
+ raise SystemExit(rc)
830
+ return
831
+ renderer = _kit_script_path("uscha-devloop", "uscha_top.py")
832
+ if renderer is None:
833
+ print("[uscha top] uscha_top.py not found in the kit", file=sys.stderr)
834
+ raise SystemExit(1)
835
+ cmd = [sys.executable, str(renderer), "--ledger", args.ledger,
836
+ "--refresh", str(args.refresh)]
837
+ if args.once:
838
+ cmd.append("--once")
839
+ rc = subprocess.call(cmd)
840
+ if rc:
841
+ raise SystemExit(rc)
842
+
843
+
810
844
  def settings_without_hook(path):
811
845
  """Return (new_settings, removed_count): the user's settings with OUR PreToolUse entries
812
846
  dropped and nothing else touched. A foreign hook -- including one in the same group -- is
@@ -1006,6 +1040,12 @@ def build_parser():
1006
1040
  mirador.add_argument("--open", dest="force_open", action="store_true",
1007
1041
  help="open the browser even if mirador.html already existed (you closed the tab)")
1008
1042
  mirador.set_defaults(func=cmd_mirador)
1043
+ top = sub.add_parser("top", help="live terminal board of the project's obligations, read from QA-LEDGER.json")
1044
+ top.add_argument("--ledger", default="QA-LEDGER.json", help="ledger to read (default: the QA-LEDGER.json convention)")
1045
+ top.add_argument("--once", action="store_true", help="print one plain frame and exit (implied without a TTY)")
1046
+ top.add_argument("--refresh", type=float, default=2.0, help="seconds between polls (reserved for M2; M1 re-reads on the `r` key)")
1047
+ top.add_argument("--json", action="store_true", help="print the engine's read-only `top --json` contract instead of rendering it")
1048
+ top.set_defaults(func=cmd_top)
1009
1049
  return parser
1010
1050
 
1011
1051
 
@@ -0,0 +1 @@
1
+ {"AC-T-01": true, "AC-T-02": true, "AC-T-03": true, "AC-T-10": true, "AC-T-04": true, "AC-T-05": true, "AC-T-06": true, "AC-T-09": true, "AC-T-24": true, "reg-quarantine-obs-null-on-measured": true, "reg-spec-pin-null-outside-worktree": true, "reg-unreachable-repo-named-not-silent": true, "AC-T-19": true, "reg-empty-project-honest": true, "AC-T-23": true, "AC-T-21": true, "AC-T-08": true, "AC-T-07": true, "AC-T-18": true, "AC-T-20": true, "AC-T-22": true, "reg-ledger-not-found": true}