@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 +2 -2
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +165 -1
- package/uscha-kit/.claude/skills/uscha-devloop/uscha_top.py +176 -30
- package/uscha-kit/.claude-plugin/plugin.json +1 -1
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +1 -1
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/reports/junit/.top-cases.json +1 -1
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +165 -1
- package/uscha-kit/skills/uscha-devloop/uscha_top.py +176 -30
- package/uscha-kit/uscha.config.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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 =
|
|
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,
|
|
143
|
-
|
|
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"
|
|
172
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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="
|
|
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.
|
|
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": {
|
package/uscha-kit/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# uscha-kit
|
|
2
2
|
|
|
3
|
-
**Kit version:** v1.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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 =
|
|
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,
|
|
143
|
-
|
|
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"
|
|
172
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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="
|
|
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
|