@andresmassello/uscha 1.87.0 → 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.
package/README.md CHANGED
@@ -40,7 +40,7 @@ Requires **Python 3.8+** on the machine (the engine is Python stdlib — no pip
40
40
  runtime dependencies). The npm package is a thin router; the canonical installer is
41
41
  `uscha-kit/install-uscha.py`.
42
42
 
43
- **Kit v1.87.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
43
+ **Kit v1.88.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
44
44
  [changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG.md)
45
45
  (the per-release changelogs live in the repo, not in the npm tarball)
46
46
 
@@ -85,7 +85,7 @@ automatic tool can perform: a human verdict.
85
85
  from the compiled code: 0.062 measured (12 archetypes) — names, not yet semantics
86
86
  ```
87
87
 
88
- **What each arrow is, in the engine (kit 1.87.0, 52 subcommands, all measured):**
88
+ **What each arrow is, in the engine (kit 1.88.0, 52 subcommands, all measured):**
89
89
 
90
90
  | Leg | Subcommands | What it establishes |
91
91
  |---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andresmassello/uscha",
3
- "version": "1.87.0",
3
+ "version": "1.88.0",
4
4
  "description": "Spec-driven development for LLM coding agents: 9 skills + a stdlib evidence engine. Facts block, guesses advise; the human approves.",
5
5
  "author": {
6
6
  "name": "Andres Massello",
@@ -8232,6 +8232,168 @@ def _top_ac_key(cid):
8232
8232
  return (2, str(cid), 0)
8233
8233
 
8234
8234
 
8235
+ TOP_EVENTS_TAIL = 8 # how many steps the feed carries; the TUI shows what fits
8236
+ TOP_EVENT_WIDTH = 72 # one feed line, short enough to survive the 80-column floor
8237
+
8238
+ # kind -> level, the FIXED map ADR-032 (amended 1.88.0, M2) requires. `level` and `text` do
8239
+ # not exist in `ledger["steps"]`; they are derived here, once, so the TUI renders a feed it
8240
+ # did not author (ADR-034). A kind absent from this map reads `info`: an unclassified step
8241
+ # is never a green one. Four kinds get their level REFINED below from the record the step
8242
+ # announces (the iteration/escalation with the same `n`, the k-th clean-room record of the
8243
+ # repo) -- and when that correlation misses, the level stays at its neutral value instead of
8244
+ # guessing a verdict.
8245
+ TOP_EVENT_LEVELS = {
8246
+ "snapshot": "info", # -> fail when the snapshot recorded red tests
8247
+ "qa-step": "info", # -> pass when nothing was reported, or all fixed
8248
+ "static-gate": "info", # -> pass/fail by the gated finding count
8249
+ "cleanroom": "info", # -> pass/fail by the record's `ok`
8250
+ "fastpath-eval": "info",
8251
+ "gate-not-run": "unmeasured", # a gate nobody ran is UNMEASURED, not a pass
8252
+ "escalation": "human",
8253
+ "escalation-resolved": "human",
8254
+ "production-finding": "human",
8255
+ "production-finding:resolve": "human",
8256
+ "spec-doubt": "human",
8257
+ "spec-doubt:resolve": "human",
8258
+ "spec-change-request": "human",
8259
+ "spec-change-request:resolve": "human",
8260
+ }
8261
+
8262
+
8263
+ def _top_ts(iso):
8264
+ """HH:MM:SS in UTC, or None. A stamp carrying an offset is normalized to UTC (machine-
8265
+ independent); it is never converted to the LOCAL zone, which would make the same ledger
8266
+ read differently on two boxes and break the golden frames."""
8267
+ dt = _top_dt(iso)
8268
+ if dt is None:
8269
+ return None
8270
+ if dt.tzinfo is not None:
8271
+ dt = dt.astimezone(timezone.utc)
8272
+ return dt.strftime("%H:%M:%S")
8273
+
8274
+
8275
+ def _top_key(value):
8276
+ """A dict key that cannot raise. The writers always put a scalar in `n`, `at` and `repo`,
8277
+ but the ledger is JSON on disk and a hand edit can leave a list or a dict there --
8278
+ `unhashable type` is not how a read-only readout gets to report that."""
8279
+ if isinstance(value, (str, int, float, bool)) or value is None:
8280
+ return value
8281
+ return repr(value)
8282
+
8283
+
8284
+ def _top_event_text(*parts):
8285
+ """One feed line: the only free text of the whole contract that reaches a terminal.
8286
+
8287
+ Its ingredients are ledger prose (an escalation reason, a tool name) -- human and CLI
8288
+ input -- so an ESC or a C0 byte inside one would be a control sequence the board prints
8289
+ verbatim. Every control character is dropped HERE, in the engine, and the renderer drops
8290
+ them again on the way out: two cheap guards over one attack surface."""
8291
+ txt = " · ".join(str(p) for p in parts if p not in (None, "", "?"))
8292
+ txt = "".join(" " if c in ("\t", "\n", "\r") else c for c in txt)
8293
+ txt = "".join(c for c in txt if ord(c) >= 32 and ord(c) != 127)
8294
+ txt = " ".join(txt.split())
8295
+ if len(txt) > TOP_EVENT_WIDTH:
8296
+ txt = txt[:TOP_EVENT_WIDTH - 1] + "…"
8297
+ return txt
8298
+
8299
+
8300
+ def _top_events(ledger, limit=TOP_EVENTS_TAIL):
8301
+ """events_tail[]: the last `limit` steps as {ts, level, text}, NEWEST FIRST.
8302
+
8303
+ Deterministic given the ledger and read-only, like the rest of `cmd_top`. The step
8304
+ records carry `n, at, kind, repo` plus a few per-kind fields; everything else the feed
8305
+ shows comes from the record that step announces, correlated the way the ledger really
8306
+ supports it: by `n` for iterations, escalations and fast-path entries (the writer copies
8307
+ the counter into both), in ORDER for clean-room records (step and record are appended in
8308
+ the same call), and by `(repo, at)` for snapshots. A miss degrades that one line to
8309
+ `info` -- under-claiming a verdict, never inventing one."""
8310
+ nodes = dict(ledger.get("repos") or {})
8311
+ nodes["integration"] = ledger.get("integration") or {}
8312
+ iters, snaps = {}, {}
8313
+ for rname, node in nodes.items():
8314
+ for it in (node or {}).get("iterations") or []:
8315
+ if isinstance(it, dict) and it.get("n") is not None:
8316
+ iters[(rname, _top_key(it.get("n")))] = it
8317
+ for sn in (node or {}).get("snapshots") or []:
8318
+ if isinstance(sn, dict):
8319
+ snaps.setdefault((rname, _top_key(sn.get("at"))), sn)
8320
+ esc = {_top_key(e.get("n")): e for e in ledger.get("escalations") or []
8321
+ if isinstance(e, dict) and e.get("n") is not None}
8322
+ fastp = {_top_key(e.get("n")): e for e in ledger.get("fast_path") or []
8323
+ if isinstance(e, dict) and e.get("n") is not None}
8324
+ crs = {}
8325
+ for rec in ledger.get(CLEAN_ROOM_KEY) or []:
8326
+ if isinstance(rec, dict):
8327
+ crs.setdefault(str(rec.get("repo") or ""), []).append(rec)
8328
+ cr_seen = {}
8329
+
8330
+ events = []
8331
+ for st in ledger.get("steps") or []:
8332
+ if not isinstance(st, dict):
8333
+ continue
8334
+ kind = str(st.get("kind") or "")
8335
+ level = TOP_EVENT_LEVELS.get(kind, "info")
8336
+ try:
8337
+ repo = str(st.get("repo") or "")
8338
+ n = _top_key(st.get("n"))
8339
+ head, tail = kind or "step", None
8340
+
8341
+ if kind == "snapshot":
8342
+ head = "snapshot " + repo if repo else "snapshot"
8343
+ tail = "phase %s" % st.get("phase") if st.get("phase") else None
8344
+ tests = (snaps.get((repo, _top_key(st.get("at")))) or {}).get("tests") or {}
8345
+ red = (tests.get("failures") or 0) + (tests.get("errors") or 0)
8346
+ if tests.get("report_found") and red:
8347
+ level, tail = "fail", "%d red test(s)" % red
8348
+ elif kind in ("qa-step", "static-gate", "gate-not-run"):
8349
+ head = "%s %s" % (kind, "/".join(str(p) for p in (repo, st.get("tool")) if p))
8350
+ it = iters.get((repo, n)) or {}
8351
+ rep, fixed = it.get("reported"), it.get("fixed")
8352
+ gated = it.get("gated_reported")
8353
+ if kind == "gate-not-run":
8354
+ tail = "not run — nobody measured it"
8355
+ elif kind == "static-gate" and isinstance(gated, int):
8356
+ level = "fail" if gated >= 1 else "pass"
8357
+ tail = "%d gated finding(s)" % gated if gated else "clean"
8358
+ elif kind == "qa-step" and isinstance(rep, int):
8359
+ if rep == 0 or (isinstance(fixed, int) and fixed >= rep):
8360
+ level = "pass"
8361
+ tail = "%d reported, %s fixed" % (rep,
8362
+ fixed if fixed is not None else "?")
8363
+ elif kind == "cleanroom":
8364
+ head = "cleanroom " + repo if repo else "cleanroom"
8365
+ queue = crs.get(repo) or []
8366
+ idx = cr_seen.get(repo, 0)
8367
+ cr_seen[repo] = idx + 1
8368
+ rec = queue[idx] if idx < len(queue) else {}
8369
+ if rec.get("status") and rec.get("ok") is not None:
8370
+ level = "pass" if rec.get("ok") else "fail"
8371
+ tail = str(rec.get("status"))
8372
+ elif kind == "fastpath-eval":
8373
+ head = "fastpath-eval " + repo if repo else "fastpath-eval"
8374
+ tail = (fastp.get(n) or {}).get("verdict")
8375
+ elif kind in ("escalation", "escalation-resolved"):
8376
+ head = "%s %s" % (kind, repo) if repo else kind
8377
+ # `escalation-resolved` gets a FRESH counter of its own, so there is no
8378
+ # record to look up: it says what happened and nothing more.
8379
+ tail = (esc.get(n) or {}).get("reason") if kind == "escalation" else None
8380
+ else:
8381
+ head = "%s %s" % (kind, repo) if repo else (kind or "step")
8382
+ tail = st.get("id")
8383
+ text = _top_event_text(head, tail)
8384
+ except Exception:
8385
+ # a ledger is JSON on disk: any field can arrive as a list, a dict or a number
8386
+ # from a hand edit. ONE unreadable step degrades to a neutral line naming its
8387
+ # kind -- the readout never raises and never loses the JSON the board needs
8388
+ # (the same fail-soft rule _top_dt already applies to timestamps).
8389
+ level, text = "info", _top_event_text(kind or "step")
8390
+
8391
+ events.append({"ts": _top_ts(st.get("at")), "level": level, "text": text})
8392
+
8393
+ events.reverse() # newest first, as the board reads top-down
8394
+ return events[:max(0, int(limit))]
8395
+
8396
+
8235
8397
  def cmd_top(args):
8236
8398
  """`uscha top` — the WHOLE projection of the ledger as one read-only JSON (ADR-032).
8237
8399
 
@@ -8348,7 +8510,9 @@ def cmd_top(args):
8348
8510
  "generated_at": _now(),
8349
8511
  "obligations": obligations,
8350
8512
  "observations": observations,
8351
- "events_tail": [], # the live feed is M2; the key ships empty, not absent
8513
+ # the live feed (M2): the last steps, newest first, with `level`/`text` derived by the
8514
+ # fixed per-kind map above -- in the engine, so the TUI authors no verdict of its own.
8515
+ "events_tail": _top_events(ledger),
8352
8516
  "counts": {"measured_pass": done, "measured_fail": fail, "quarantine": quar,
8353
8517
  "unmeasured": _n("UNMEASURED"), "traced": 0, "tagged": 0, "total": total},
8354
8518
  "terminado": {"done": done, "total": total, "pct": pct, "unmeasured": unmeasured},
@@ -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.87.0",
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.87.0",
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.87.0 <!-- 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.87.0
1
+ uscha-kit 1.88.0
@@ -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}
@@ -8232,6 +8232,168 @@ def _top_ac_key(cid):
8232
8232
  return (2, str(cid), 0)
8233
8233
 
8234
8234
 
8235
+ TOP_EVENTS_TAIL = 8 # how many steps the feed carries; the TUI shows what fits
8236
+ TOP_EVENT_WIDTH = 72 # one feed line, short enough to survive the 80-column floor
8237
+
8238
+ # kind -> level, the FIXED map ADR-032 (amended 1.88.0, M2) requires. `level` and `text` do
8239
+ # not exist in `ledger["steps"]`; they are derived here, once, so the TUI renders a feed it
8240
+ # did not author (ADR-034). A kind absent from this map reads `info`: an unclassified step
8241
+ # is never a green one. Four kinds get their level REFINED below from the record the step
8242
+ # announces (the iteration/escalation with the same `n`, the k-th clean-room record of the
8243
+ # repo) -- and when that correlation misses, the level stays at its neutral value instead of
8244
+ # guessing a verdict.
8245
+ TOP_EVENT_LEVELS = {
8246
+ "snapshot": "info", # -> fail when the snapshot recorded red tests
8247
+ "qa-step": "info", # -> pass when nothing was reported, or all fixed
8248
+ "static-gate": "info", # -> pass/fail by the gated finding count
8249
+ "cleanroom": "info", # -> pass/fail by the record's `ok`
8250
+ "fastpath-eval": "info",
8251
+ "gate-not-run": "unmeasured", # a gate nobody ran is UNMEASURED, not a pass
8252
+ "escalation": "human",
8253
+ "escalation-resolved": "human",
8254
+ "production-finding": "human",
8255
+ "production-finding:resolve": "human",
8256
+ "spec-doubt": "human",
8257
+ "spec-doubt:resolve": "human",
8258
+ "spec-change-request": "human",
8259
+ "spec-change-request:resolve": "human",
8260
+ }
8261
+
8262
+
8263
+ def _top_ts(iso):
8264
+ """HH:MM:SS in UTC, or None. A stamp carrying an offset is normalized to UTC (machine-
8265
+ independent); it is never converted to the LOCAL zone, which would make the same ledger
8266
+ read differently on two boxes and break the golden frames."""
8267
+ dt = _top_dt(iso)
8268
+ if dt is None:
8269
+ return None
8270
+ if dt.tzinfo is not None:
8271
+ dt = dt.astimezone(timezone.utc)
8272
+ return dt.strftime("%H:%M:%S")
8273
+
8274
+
8275
+ def _top_key(value):
8276
+ """A dict key that cannot raise. The writers always put a scalar in `n`, `at` and `repo`,
8277
+ but the ledger is JSON on disk and a hand edit can leave a list or a dict there --
8278
+ `unhashable type` is not how a read-only readout gets to report that."""
8279
+ if isinstance(value, (str, int, float, bool)) or value is None:
8280
+ return value
8281
+ return repr(value)
8282
+
8283
+
8284
+ def _top_event_text(*parts):
8285
+ """One feed line: the only free text of the whole contract that reaches a terminal.
8286
+
8287
+ Its ingredients are ledger prose (an escalation reason, a tool name) -- human and CLI
8288
+ input -- so an ESC or a C0 byte inside one would be a control sequence the board prints
8289
+ verbatim. Every control character is dropped HERE, in the engine, and the renderer drops
8290
+ them again on the way out: two cheap guards over one attack surface."""
8291
+ txt = " · ".join(str(p) for p in parts if p not in (None, "", "?"))
8292
+ txt = "".join(" " if c in ("\t", "\n", "\r") else c for c in txt)
8293
+ txt = "".join(c for c in txt if ord(c) >= 32 and ord(c) != 127)
8294
+ txt = " ".join(txt.split())
8295
+ if len(txt) > TOP_EVENT_WIDTH:
8296
+ txt = txt[:TOP_EVENT_WIDTH - 1] + "…"
8297
+ return txt
8298
+
8299
+
8300
+ def _top_events(ledger, limit=TOP_EVENTS_TAIL):
8301
+ """events_tail[]: the last `limit` steps as {ts, level, text}, NEWEST FIRST.
8302
+
8303
+ Deterministic given the ledger and read-only, like the rest of `cmd_top`. The step
8304
+ records carry `n, at, kind, repo` plus a few per-kind fields; everything else the feed
8305
+ shows comes from the record that step announces, correlated the way the ledger really
8306
+ supports it: by `n` for iterations, escalations and fast-path entries (the writer copies
8307
+ the counter into both), in ORDER for clean-room records (step and record are appended in
8308
+ the same call), and by `(repo, at)` for snapshots. A miss degrades that one line to
8309
+ `info` -- under-claiming a verdict, never inventing one."""
8310
+ nodes = dict(ledger.get("repos") or {})
8311
+ nodes["integration"] = ledger.get("integration") or {}
8312
+ iters, snaps = {}, {}
8313
+ for rname, node in nodes.items():
8314
+ for it in (node or {}).get("iterations") or []:
8315
+ if isinstance(it, dict) and it.get("n") is not None:
8316
+ iters[(rname, _top_key(it.get("n")))] = it
8317
+ for sn in (node or {}).get("snapshots") or []:
8318
+ if isinstance(sn, dict):
8319
+ snaps.setdefault((rname, _top_key(sn.get("at"))), sn)
8320
+ esc = {_top_key(e.get("n")): e for e in ledger.get("escalations") or []
8321
+ if isinstance(e, dict) and e.get("n") is not None}
8322
+ fastp = {_top_key(e.get("n")): e for e in ledger.get("fast_path") or []
8323
+ if isinstance(e, dict) and e.get("n") is not None}
8324
+ crs = {}
8325
+ for rec in ledger.get(CLEAN_ROOM_KEY) or []:
8326
+ if isinstance(rec, dict):
8327
+ crs.setdefault(str(rec.get("repo") or ""), []).append(rec)
8328
+ cr_seen = {}
8329
+
8330
+ events = []
8331
+ for st in ledger.get("steps") or []:
8332
+ if not isinstance(st, dict):
8333
+ continue
8334
+ kind = str(st.get("kind") or "")
8335
+ level = TOP_EVENT_LEVELS.get(kind, "info")
8336
+ try:
8337
+ repo = str(st.get("repo") or "")
8338
+ n = _top_key(st.get("n"))
8339
+ head, tail = kind or "step", None
8340
+
8341
+ if kind == "snapshot":
8342
+ head = "snapshot " + repo if repo else "snapshot"
8343
+ tail = "phase %s" % st.get("phase") if st.get("phase") else None
8344
+ tests = (snaps.get((repo, _top_key(st.get("at")))) or {}).get("tests") or {}
8345
+ red = (tests.get("failures") or 0) + (tests.get("errors") or 0)
8346
+ if tests.get("report_found") and red:
8347
+ level, tail = "fail", "%d red test(s)" % red
8348
+ elif kind in ("qa-step", "static-gate", "gate-not-run"):
8349
+ head = "%s %s" % (kind, "/".join(str(p) for p in (repo, st.get("tool")) if p))
8350
+ it = iters.get((repo, n)) or {}
8351
+ rep, fixed = it.get("reported"), it.get("fixed")
8352
+ gated = it.get("gated_reported")
8353
+ if kind == "gate-not-run":
8354
+ tail = "not run — nobody measured it"
8355
+ elif kind == "static-gate" and isinstance(gated, int):
8356
+ level = "fail" if gated >= 1 else "pass"
8357
+ tail = "%d gated finding(s)" % gated if gated else "clean"
8358
+ elif kind == "qa-step" and isinstance(rep, int):
8359
+ if rep == 0 or (isinstance(fixed, int) and fixed >= rep):
8360
+ level = "pass"
8361
+ tail = "%d reported, %s fixed" % (rep,
8362
+ fixed if fixed is not None else "?")
8363
+ elif kind == "cleanroom":
8364
+ head = "cleanroom " + repo if repo else "cleanroom"
8365
+ queue = crs.get(repo) or []
8366
+ idx = cr_seen.get(repo, 0)
8367
+ cr_seen[repo] = idx + 1
8368
+ rec = queue[idx] if idx < len(queue) else {}
8369
+ if rec.get("status") and rec.get("ok") is not None:
8370
+ level = "pass" if rec.get("ok") else "fail"
8371
+ tail = str(rec.get("status"))
8372
+ elif kind == "fastpath-eval":
8373
+ head = "fastpath-eval " + repo if repo else "fastpath-eval"
8374
+ tail = (fastp.get(n) or {}).get("verdict")
8375
+ elif kind in ("escalation", "escalation-resolved"):
8376
+ head = "%s %s" % (kind, repo) if repo else kind
8377
+ # `escalation-resolved` gets a FRESH counter of its own, so there is no
8378
+ # record to look up: it says what happened and nothing more.
8379
+ tail = (esc.get(n) or {}).get("reason") if kind == "escalation" else None
8380
+ else:
8381
+ head = "%s %s" % (kind, repo) if repo else (kind or "step")
8382
+ tail = st.get("id")
8383
+ text = _top_event_text(head, tail)
8384
+ except Exception:
8385
+ # a ledger is JSON on disk: any field can arrive as a list, a dict or a number
8386
+ # from a hand edit. ONE unreadable step degrades to a neutral line naming its
8387
+ # kind -- the readout never raises and never loses the JSON the board needs
8388
+ # (the same fail-soft rule _top_dt already applies to timestamps).
8389
+ level, text = "info", _top_event_text(kind or "step")
8390
+
8391
+ events.append({"ts": _top_ts(st.get("at")), "level": level, "text": text})
8392
+
8393
+ events.reverse() # newest first, as the board reads top-down
8394
+ return events[:max(0, int(limit))]
8395
+
8396
+
8235
8397
  def cmd_top(args):
8236
8398
  """`uscha top` — the WHOLE projection of the ledger as one read-only JSON (ADR-032).
8237
8399
 
@@ -8348,7 +8510,9 @@ def cmd_top(args):
8348
8510
  "generated_at": _now(),
8349
8511
  "obligations": obligations,
8350
8512
  "observations": observations,
8351
- "events_tail": [], # the live feed is M2; the key ships empty, not absent
8513
+ # the live feed (M2): the last steps, newest first, with `level`/`text` derived by the
8514
+ # fixed per-kind map above -- in the engine, so the TUI authors no verdict of its own.
8515
+ "events_tail": _top_events(ledger),
8352
8516
  "counts": {"measured_pass": done, "measured_fail": fail, "quarantine": quar,
8353
8517
  "unmeasured": _n("UNMEASURED"), "traced": 0, "tagged": 0, "total": total},
8354
8518
  "terminado": {"done": done, "total": total, "pct": pct, "unmeasured": unmeasured},
@@ -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,5 +1,5 @@
1
1
  {
2
- "version": "1.87.0",
2
+ "version": "1.88.0",
3
3
  "project": null,
4
4
  "defaults": {
5
5
  "coverage_threshold": 60,