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