@andresmassello/uscha 1.86.1 → 1.88.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.
@@ -13,8 +13,8 @@ Truth-pass (INV-TOP-05): a field the engine emits as null renders as an em dash,
13
13
  zero and never as a guess. In v0.1 that is ETA, every AGE, drift, and the trace column --
14
14
  each with its deferred wiring recorded in ADR-035.
15
15
 
16
- 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.
16
+ M2 scope: the read-only BOARD plus the live feed and its mtime poll. VERDICTS mode (M3) is
17
+ not wired; the pane that will hold it is labelled as such rather than faked.
18
18
 
19
19
  Stdlib only. Python 3.8+. Runnable directly or via `python -m uscha_top`.
20
20
  """
@@ -25,6 +25,7 @@ import os
25
25
  import shutil
26
26
  import subprocess
27
27
  import sys
28
+ import time
28
29
 
29
30
  DEFAULT_LEDGER = "QA-LEDGER.json"
30
31
  FALLBACK_SIZE = (100, 32)
@@ -32,8 +33,9 @@ FALLBACK_SIZE = (100, 32)
32
33
  # Lines the board always spends on chrome: the title, 3 rules, 4 KPI lines, the table
33
34
  # header, the feed label and the key hint. Everything else is table rows + feed.
34
35
  CHROME_LINES = 11
35
- FEED_MAX = 3
36
+ FEED_MAX = 8 # = the engine's events_tail length; a short terminal shows fewer
36
37
  BURNUP_MAX = 24
38
+ MIN_REFRESH = 0.5 # a poll faster than this is a busy loop, not a refresh
37
39
 
38
40
  # ANSI SGR by obligation state. TRACED and TAGGED deliberately share the UNMEASURED gray:
39
41
  # the v0.1 engine has no source for either rung (ADR-032), so they must read as "not
@@ -52,6 +54,19 @@ MID = "·"
52
54
  RULE = "─"
53
55
  BLOCKS = "▁▂▃▄▅▆▇█"
54
56
 
57
+ # Feed levels: one letter and one colour each. The LETTER carries the level on the plain
58
+ # path (golden frames, pipes, CI) and the colour only decorates that same letter on a real
59
+ # terminal -- so both paths have identical geometry and a snapshot compares text, never
60
+ # terminal control codes. `info` is deliberately uncoloured: it is the level an unclassified
61
+ # step falls back to, and it must not look like a verdict.
62
+ FEED_LEVELS = {
63
+ "pass": ("P", "32"),
64
+ "fail": ("F", "31"),
65
+ "human": ("H", "33"),
66
+ "unmeasured": ("U", "90"),
67
+ "info": ("I", ""),
68
+ }
69
+
55
70
  # What the reader is expected to DO about a row. Presentation, not a KPI: no number here.
56
71
  ACTIONS = {
57
72
  "MEASURED_PASS": DASH,
@@ -121,12 +136,16 @@ def _burnup_line(burnup, cols):
121
136
  def _spec_pin_text(spec_pin):
122
137
  """git HEAD, labelled for what it is. There is no pinned-spec concept in the engine yet
123
138
  (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)."""
139
+ em dash rather than a fabricated pin (AC-T-06, INV-TOP-05).
140
+
141
+ The sha is state-supplied text like any other, so it goes through `_safe`: it shares a
142
+ line with no colour of its own, but a frozen state carrying an escape here would put one
143
+ in the header, and the header is the one line every frame has."""
125
144
  if not spec_pin or not spec_pin.get("sha"):
126
145
  return "spec_pin " + DASH
127
146
  mark = ("clean-room verified" if spec_pin.get("clean_room_verified")
128
147
  else "not clean-room verified")
129
- return "spec_pin %s (%s)" % (spec_pin["sha"], mark)
148
+ return "spec_pin %s (%s)" % (_safe(spec_pin["sha"]), mark)
130
149
 
131
150
 
132
151
  def _cases_text(ob):
@@ -139,11 +158,30 @@ def _cases_text(ob):
139
158
  def _row(ob, selected):
140
159
  gutter = "> " if selected else " "
141
160
  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),
161
+ gutter, _safe(ob.get("id") or "?")[:8], _safe(ob.get("gate") or DASH)[:8],
162
+ _safe(ob.get("state") or "?")[:14], _cases_text(ob),
144
163
  _num(ob.get("age_hours")), ACTIONS.get(ob.get("state"), DASH))
145
164
 
146
165
 
166
+ def _safe(text):
167
+ """No control character reaches the terminal through the feed. The engine already
168
+ strips them where the text is derived (`_top_event_text`); this is the second guard on
169
+ the same surface, because the renderer also accepts a frozen state file a human wrote,
170
+ and one ESC in it would be a control sequence the board obeys instead of prints."""
171
+ return "".join(c for c in str(text or "") if ord(c) >= 32 and ord(c) != 127)
172
+
173
+
174
+ def _feed_line(ev, cols, plain):
175
+ """`HH:MM:SS L text` -- the level letter is the level, the colour only decorates it,
176
+ so the plain frame carries exactly the same information as the coloured one."""
177
+ letter, sgr = FEED_LEVELS.get(ev.get("level"), FEED_LEVELS["info"])
178
+ line = _fit(" %s %s %s" % (_safe(ev.get("ts")) or DASH, letter,
179
+ _safe(ev.get("text"))), cols)
180
+ if plain or not sgr:
181
+ return line
182
+ return line.replace(" %s " % letter, " \x1b[%sm%s%s " % (sgr, letter, RESET), 1)
183
+
184
+
147
185
  def _colorize(line, state):
148
186
  code = PALETTE.get(state)
149
187
  if not code or state not in line:
@@ -167,9 +205,14 @@ def render(state, size, sel=0, plain=True):
167
205
  debtors = state.get("debtors") or {}
168
206
  honesty = state.get("honesty") or {}
169
207
 
208
+ # every string the STATE supplies goes through _safe on its way into a line (project,
209
+ # spec_pin, the row cells, the feed): after that the only escapes in a frame are the
210
+ # ones this renderer put there, which is what lets the final width pass leave coloured
211
+ # lines alone without a state file being able to smuggle one in (or widen a line).
170
212
  out = []
171
- out.append(_spread("uscha top %s %s" % (MID, state.get("project") or "(unnamed project)"),
172
- "step #%s" % _num(state.get("step")), cols))
213
+ out.append(_spread("uscha top %s %s"
214
+ % (MID, _safe(state.get("project")) or "(unnamed project)"),
215
+ "step #%s" % _safe(_num(state.get("step"))), cols))
173
216
  out.append(RULE * cols)
174
217
  out.append(_pct_line(terminado))
175
218
  out.append("machine owes %s %s you owe %s %s untagged %s %s ETA %s"
@@ -177,9 +220,12 @@ def render(state, size, sel=0, plain=True):
177
220
  _num(debtors.get("untagged")), MID, _num(state.get("eta_min"))))
178
221
  # honesty travels BESIDE done on purpose (INV-TOP-04): a thin denominator has to be
179
222
  # 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"))))
223
+ # fitted HERE, at construction, not only by the pass at the end: this line carries the
224
+ # longest state-supplied string of the header, and the end pass skips coloured lines.
225
+ out.append(_fit("honesty %s/%s (%s%%) measured %s %s"
226
+ % (_num(honesty.get("measured")), _num(honesty.get("total")),
227
+ _num(honesty.get("pct")), MID,
228
+ _spec_pin_text(state.get("spec_pin"))), cols))
183
229
  out.append(_burnup_line(state.get("burnup"), cols))
184
230
  out.append(RULE * cols)
185
231
  out.append(" %-8s%-9s%-15s%7s%5s %s"
@@ -214,17 +260,31 @@ def render(state, size, sel=0, plain=True):
214
260
  pad = avail - len(table[:max(1, table_n)]) - feed_n
215
261
  out.extend([""] * max(0, pad))
216
262
  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)
263
+ events = [e for e in (state.get("events_tail") or []) if isinstance(e, dict)]
264
+ shown = events[:feed_n]
265
+ if not events:
266
+ # honest empty label: a ledger with no steps has nothing to feed, and saying so is
267
+ # not the same statement as an idle feed with the lines scrolled away (INV-TOP-05).
268
+ out.append("feed %s no ledger step recorded yet (nothing to show)" % MID)
269
+ elif not shown:
270
+ # the board is served first (AC-T-21), so at the 80x24 floor with a long table the
271
+ # feed can lose every line. It says so; it does not pretend the ledger is quiet.
272
+ out.append("feed %s 0/%d %s no room at this size (the board is served first)"
273
+ % (MID, len(events), MID))
274
+ else:
275
+ # `3/8` says out loud that the pane is showing three of the eight steps the engine
276
+ # sent: a feed that silently drops lines is a feed that can hide the red one.
277
+ out.append("feed %s %d/%d %s newest first %s P/F/H/U/I = pass/fail/human/"
278
+ "unmeasured/info" % (MID, len(shown), len(events), MID, MID))
219
279
  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("")
280
+ out.append(_feed_line(shown[i], cols, plain) if i < len(shown) else "")
225
281
  out.append("[j/k] move %s [r] reload %s [q] quit %s [v] verdicts (M3) %s "
226
282
  "[d]/[o] phase 2" % (MID, MID, MID, MID))
227
- out = [_fit(line, cols) for line in out]
283
+ # a coloured line was already fitted BEFORE its escape bytes went in (table rows and
284
+ # feed lines both), and re-fitting it here would count those bytes as visible width --
285
+ # cutting the coloured frame ~9 characters shorter than the plain one it is supposed to
286
+ # match. Fit only what carries no escapes; the golden frames are that path exactly.
287
+ out = [line if "\x1b" in line else _fit(line, cols) for line in out]
228
288
  # exactly `rows` lines: a frame that drifts in height is a frame no snapshot can pin
229
289
  out = out[:rows] + [""] * max(0, rows - len(out))
230
290
  return out
@@ -313,6 +373,63 @@ def read_key():
313
373
  termios.tcsetattr(fd, termios.TCSADRAIN, saved)
314
374
 
315
375
 
376
+ def wait_key(timeout):
377
+ """One keypress, or "" when `timeout` seconds pass first. This is what makes the poll
378
+ possible without a busy loop AND without a key that waits for the next tick to be seen:
379
+ POSIX blocks in `select` (raw mode held for the whole window, so a single byte is
380
+ readable the instant it arrives), Windows walks `msvcrt.kbhit` in short slices."""
381
+ if os.name == "nt":
382
+ import msvcrt
383
+ deadline = time.time() + max(0.0, timeout)
384
+ while True:
385
+ if msvcrt.kbhit():
386
+ return read_key()
387
+ if time.time() >= deadline:
388
+ return ""
389
+ time.sleep(0.03)
390
+ import select
391
+ import termios
392
+ import tty
393
+ fd = sys.stdin.fileno()
394
+ try:
395
+ saved = termios.tcgetattr(fd)
396
+ except Exception:
397
+ return "" # no terminal to read: never block
398
+ try:
399
+ tty.setraw(fd)
400
+ ready, _, _ = select.select([sys.stdin], [], [], max(0.0, timeout))
401
+ return sys.stdin.read(1) if ready else ""
402
+ finally:
403
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
404
+
405
+
406
+ def _changed(paths, seen):
407
+ """(changed?, new snapshot) for a set of files, by (mtime, size).
408
+
409
+ The whole of the M2 poll: no server, no watcher, no thread (ADR-031). Kept as a small
410
+ pure-ish function on purpose -- it is the piece the suite can actually drive (AC-T-12),
411
+ while a real TTY session is not. A path that cannot be stat'ed records None instead of
412
+ raising: a ledger deleted under the app is a CHANGE, not a crash."""
413
+ now = {}
414
+ for path in paths or []:
415
+ if not path:
416
+ continue
417
+ try:
418
+ st = os.stat(path)
419
+ now[path] = (st.st_mtime, st.st_size)
420
+ except OSError:
421
+ now[path] = None
422
+ return now != (seen if seen is not None else {}), now
423
+
424
+
425
+ def watch_paths(args):
426
+ """What the poll watches: the frozen state file when one is given, otherwise the ledger
427
+ the engine reads. Nothing else -- `discovery/CANDIDATE-DELTA.json` is NOT watched in
428
+ v0.1 (the state carries no path to it), so a `discover` run that leaves the ledger
429
+ untouched is seen on the next `r`, not on the next tick. Under-claim, then wire."""
430
+ return [args.state] if getattr(args, "state", None) else [getattr(args, "ledger", None)]
431
+
432
+
316
433
  def dispatch(key, sel, count):
317
434
  """Key -> (new selection, quit?, reload?). Pure, so the keymap is testable without a
318
435
  terminal: the driver below is not what is under test, this dispatch is (ADR-034)."""
@@ -339,20 +456,48 @@ def _print_frame(lines):
339
456
  sys.stdout.flush()
340
457
 
341
458
 
459
+ def _reload(state, args):
460
+ """Re-read, or keep what is on screen. A poll that catches the ledger MID-WRITE reads a
461
+ truncated file; the last good board plus a retry next tick is honest, a traceback over
462
+ a working terminal is not."""
463
+ try:
464
+ return load_state(args.state, args.ledger)
465
+ except (OSError, ValueError, RuntimeError):
466
+ return state
467
+
468
+
342
469
  def _loop(state, args):
343
470
  sel = 0
471
+ interval = max(MIN_REFRESH, float(args.refresh or 0))
472
+ paths = watch_paths(args)
473
+ _seed, seen = _changed(paths, {}) # the first frame is already current
474
+ dirty = True
344
475
  sys.stdout.write("\x1b[?25l")
345
476
  try:
346
477
  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)
478
+ if dirty:
479
+ frame = render(state, terminal_size(args.cols, args.rows),
480
+ sel=sel, plain=False)
481
+ sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(frame))
482
+ sys.stdout.flush()
483
+ dirty = False
484
+ # one wait serves both jobs: a key answers immediately, and the deadline is the
485
+ # `--refresh` tick that re-reads only when a watched file actually moved.
486
+ key = wait_key(interval)
487
+ if key:
488
+ sel, quit_now, reload_now = dispatch(
489
+ key, sel, len(state.get("obligations") or []))
490
+ if quit_now:
491
+ return 0
492
+ if reload_now:
493
+ state = _reload(state, args)
494
+ _fresh, seen = _changed(paths, seen)
495
+ dirty = True
496
+ continue
497
+ moved, seen = _changed(paths, seen)
498
+ if moved:
499
+ state = _reload(state, args)
500
+ dirty = True
356
501
  except KeyboardInterrupt:
357
502
  return 0
358
503
  finally:
@@ -374,7 +519,8 @@ def build_parser():
374
519
  parser.add_argument("--plain", action="store_true",
375
520
  help="never emit escape sequences")
376
521
  parser.add_argument("--refresh", type=float, default=2.0,
377
- help="reserved for the M2 timed poll; M1 re-reads on the `r` key")
522
+ help="seconds between mtime polls of the ledger (default: 2, "
523
+ "floor %.1f); `r` still forces a re-read" % MIN_REFRESH)
378
524
  parser.add_argument("--cols", type=int, default=None)
379
525
  parser.add_argument("--rows", type=int, default=None)
380
526
  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.86.1",
4
+ "version": "1.88.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.86.1",
3
+ "version": "1.88.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.86.1 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
3
+ **Kit version:** v1.88.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.86.1
1
+ uscha-kit 1.88.0
@@ -0,0 +1 @@
1
+ {"AC-FA-01": true, "AC-FA-02": true, "AC-FA-03": null, "AC-FA-04": true, "AC-FA-05": true, "AC-FA-06": true}
@@ -1 +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}
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, "reg-top-events-malformed-fields-degrade": true, "AC-T-11": 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, "AC-T-12": true, "reg-top-render-state-text-cannot-widen-or-escape": true}
@@ -535,7 +535,10 @@ once logged it caps readiness ≤65 and blocks convergence until resolved with
535
535
  criterion carries a stable ID: `- [ ] AC-01 — when X then Y`. A criterion counts as
536
536
  CLOSED only when ≥1 GREEN testcase whose name carries the tag (`test_ac1_x`,
537
537
  `testAC01X`, `"AC-01: ..."` — IDs normalize by number, `AC-01 == AC_1 == ac1`) exists
538
- in the ingested JUnit reports AND no tagged testcase is red. The checkbox is the
538
+ in the ingested JUnit reports AND no tagged testcase is red. Since kit 1.87.0 (ADR-036)
539
+ a FAMILY prefix is read the same way: `- [ ] AC-BC-07 — ...` closes on `AC-BC-07_x`,
540
+ `test_ac_bc_7_y` or `AC_BC_7` (normalized to `AC-BC-7`; the family needs a separator on
541
+ both sides — camelCase `testACBC07` is NOT a tag, and `AC-7-x` is still the bare `AC-7`). The checkbox is the
539
542
  NARRATIVE; the testcase is the FACT — a checked box without a green tagged test shows
540
543
  up as `narrated_only` and does NOT close (measured beats narrated, per criterion).
541
544
  A JUnit report older than the repo's source code is treated as STALE (the code changed