@andresmassello/uscha 1.90.0 → 1.91.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 +4 -4
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +77 -0
- package/uscha-kit/.claude/skills/uscha-devloop/uscha_top.py +348 -7
- package/uscha-kit/.claude-plugin/plugin.json +1 -1
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +2 -6
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/install-uscha.py +5 -0
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +77 -0
- package/uscha-kit/skills/uscha-devloop/uscha_top.py +348 -7
- package/uscha-kit/uscha.config.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ the rest.* Uscha gives a coding agent a spec to build against, a QA loop that co
|
|
|
5
5
|
instead of looping forever, and a deterministic ledger that records what was **measured** —
|
|
6
6
|
never what was claimed.
|
|
7
7
|
|
|
8
|
-
> The
|
|
8
|
+
> The agent executes · the method governs · evidence decides · the human approves.
|
|
9
9
|
|
|
10
10
|
**[uscha.dev](https://uscha.dev)** — the method, the five rules, the skills, the library
|
|
11
11
|
(the diamond thesis, how-it-works diagrams, essay, 2-day dev course, reference, paper).
|
|
@@ -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.91.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,13 +85,13 @@ automatic tool can perform: a human verdict.
|
|
|
85
85
|
from the compiled code: 0.828 measured (12 archetypes) — names AND behaviour
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
**What each arrow is, in the engine (kit 1.
|
|
88
|
+
**What each arrow is, in the engine (kit 1.91.0, 52 subcommands, all measured):**
|
|
89
89
|
|
|
90
90
|
| Leg | Subcommands | What it establishes |
|
|
91
91
|
|---|---|---|
|
|
92
92
|
| Asset → typed graph | `ir-extract`, `ir-render` | the whole package becomes one canonical IR (M2, ADR-015) — deterministic, `UNTYPED` is a measurement not an error |
|
|
93
93
|
| Forward, the compiler | `compile-validate`, `compile-ingest` | any model produces code; the engine validates the output contract and never compiles (M3, ADR-016) |
|
|
94
|
-
| Forward, is it the *same* system? | `bootstrap-oracle`, `bootstrap-variance`, `bench` | a withheld oracle judges blind compilations — **12 archetypes, 9 PASS · 3 PARTIAL**, three models, JS included (M4/M5, ADR-017/018/028/029) |
|
|
94
|
+
| Forward, is it the *same* system? | `bootstrap-oracle`, `bootstrap-variance`, `bench` | a withheld oracle judges blind compilations — **12 archetypes, 9 PASS · 3 PARTIAL**, three Claude-family models (Haiku · Sonnet · Opus — one vendor; cross-vendor not yet measured), JS included (M4/M5, ADR-017/018/028/029) |
|
|
95
95
|
| Reverse, facts | `discover`, `golden-diff` (+ the `/uscha-characterize` skill) | system map + mechanically captured golden; typed candidate observations with evidence class (M1, ADR-013) |
|
|
96
96
|
| Reverse, the human gate | `curate`, `promote`, `curation-check`, `bench-curate` | one verdict per candidate, append-only ledger verified against git; unjudged → `pr-ready` blocked naming it (ADR-009/010, INV-CURATION-01) |
|
|
97
97
|
| Fidelity, honestly | `fidelity`, `roundtrip`, `bench-roundtrip`, `bench-r2` | per-compiler fidelity vector, id-level round trip, recoverability **0.828**, and the **noise floor** under every variance claim (ADR-014/022/027/030) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andresmassello/uscha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.91.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",
|
|
@@ -8567,6 +8567,78 @@ def _top_events(ledger, limit=TOP_EVENTS_TAIL):
|
|
|
8567
8567
|
return events[:max(0, int(limit))]
|
|
8568
8568
|
|
|
8569
8569
|
|
|
8570
|
+
def _top_repos(ledger):
|
|
8571
|
+
"""The configured repos, name and configured path, in configuration order (phase 2).
|
|
8572
|
+
|
|
8573
|
+
Two things the TUI must not decide for itself now have a source: WHICH repo a rerun runs
|
|
8574
|
+
in and ingests for (ADR-037 picks the first configured one, exactly as `_top_spec_pin`
|
|
8575
|
+
picks the sha it labels), and WHICH repo the `d` pane names when it tells the reader how
|
|
8576
|
+
to produce the spec-drift run that is missing. `path` is the path as CONFIGURED --
|
|
8577
|
+
relative to the ledger, never resolved here: an absolute machine path in the contract is
|
|
8578
|
+
a frozen state nobody else can render (the golden frames are files in a repo)."""
|
|
8579
|
+
out = []
|
|
8580
|
+
for r in ((ledger.get("config", {}) or {}).get("repos") or []):
|
|
8581
|
+
if isinstance(r, dict) and r.get("name"):
|
|
8582
|
+
out.append({"name": _top_clean(r["name"]), "path": _top_clean(r.get("path", "."))})
|
|
8583
|
+
return out
|
|
8584
|
+
|
|
8585
|
+
|
|
8586
|
+
def _top_spec_diff(ledger):
|
|
8587
|
+
"""The advisory spec↔code drift the ledger ALREADY carries, projected for `d` (ADR-037).
|
|
8588
|
+
|
|
8589
|
+
It measures NOTHING: `spec-drift` (ADR-005) is the only command that walks git for this,
|
|
8590
|
+
and `top` is read-only by contract (ADR-032) -- so this reads `ledger["spec_drift"]`, the
|
|
8591
|
+
latest-state record that command leaves behind, and nothing else. **No recorded run ->
|
|
8592
|
+
`null`**, which the TUI renders as "no spec-drift run recorded", never as "no drift":
|
|
8593
|
+
"nobody measured" and "nothing is stale" are different statements and only one of them is
|
|
8594
|
+
ever free (INV-TOP-05).
|
|
8595
|
+
|
|
8596
|
+
Only `SPEC_STALE` rows travel: CLEAN/UNMAPPED/UNTRACKED/NO-CODE are the four ways a doc
|
|
8597
|
+
is NOT drifting, and `docs_total` keeps the denominator visible beside the count so the
|
|
8598
|
+
pane can say `3 of 21`. `code_ref` is ONE of the governed files that outran the doc --
|
|
8599
|
+
the record stores a capped, alphabetically sorted list and no per-file dates, so it is
|
|
8600
|
+
"a newer file", never "the newest one" (under-claim; `newer_files_total` carries the
|
|
8601
|
+
real cardinality)."""
|
|
8602
|
+
rec = ledger.get("spec_drift")
|
|
8603
|
+
if not isinstance(rec, dict):
|
|
8604
|
+
return None
|
|
8605
|
+
results = rec.get("results")
|
|
8606
|
+
results = results if isinstance(results, list) else []
|
|
8607
|
+
docs = [r for r in results if isinstance(r, dict)]
|
|
8608
|
+
stale = []
|
|
8609
|
+
for r in docs:
|
|
8610
|
+
if r.get("verdict") != "SPEC_STALE":
|
|
8611
|
+
continue
|
|
8612
|
+
# `newer_files` is a LIST in the record `spec-drift` writes, but the ledger is JSON on
|
|
8613
|
+
# disk: a hand edit can leave a string there, and a string is iterable -- the old
|
|
8614
|
+
# comprehension would have walked its characters and named `"n"` as the governed file
|
|
8615
|
+
# that outran the doc. A non-list is no evidence, so it yields no code_ref and 0.
|
|
8616
|
+
nf = r.get("newer_files")
|
|
8617
|
+
newer = [f for f in nf if isinstance(f, str)] if isinstance(nf, list) else []
|
|
8618
|
+
lag = r.get("lag_days_actual")
|
|
8619
|
+
total = r.get("newer_files_total")
|
|
8620
|
+
stale.append({
|
|
8621
|
+
"doc": _top_clean(r.get("file") or "?"),
|
|
8622
|
+
"lag_days": lag if isinstance(lag, (int, float)) else None,
|
|
8623
|
+
"code_ref": _top_clean(newer[0]) if newer else None,
|
|
8624
|
+
"newer_files_total": total if isinstance(total, int) else len(newer),
|
|
8625
|
+
"spec_committed_at": (_top_clean(r["spec_committed_at"])
|
|
8626
|
+
if r.get("spec_committed_at") else None),
|
|
8627
|
+
"newest_governed_at": (_top_clean(r["newest_governed_at"])
|
|
8628
|
+
if r.get("newest_governed_at") else None)})
|
|
8629
|
+
# worst lag first, the doc name as the tie-break: deterministic given the record, which
|
|
8630
|
+
# is what lets a golden frame be the oracle for this pane too (ADR-034).
|
|
8631
|
+
stale.sort(key=lambda s: (-(s["lag_days"] or 0), s["doc"]))
|
|
8632
|
+
lag_days = rec.get("max_lag_days")
|
|
8633
|
+
return {"measured_at": _top_clean(rec["at"]) if rec.get("at") else None,
|
|
8634
|
+
"repo": _top_clean(rec["repo"]) if rec.get("repo") else None,
|
|
8635
|
+
"max_lag_days": lag_days if isinstance(lag_days, int) else None,
|
|
8636
|
+
"docs_total": len(docs),
|
|
8637
|
+
"stale": stale,
|
|
8638
|
+
"advisory": True, # ADR-005: this never gates, here or anywhere
|
|
8639
|
+
"source": "spec-drift"}
|
|
8640
|
+
|
|
8641
|
+
|
|
8570
8642
|
def cmd_top(args):
|
|
8571
8643
|
"""`uscha top` — the WHOLE projection of the ledger as one read-only JSON (ADR-032).
|
|
8572
8644
|
|
|
@@ -8699,6 +8771,11 @@ def cmd_top(args):
|
|
|
8699
8771
|
"medians": {"verdict_min": None, "loop_min": _top_loop_median_min(ledger)},
|
|
8700
8772
|
"checks": _top_checks(ledger),
|
|
8701
8773
|
"drift_pct": None, # spec_drift is per-file; an aggregate is ADR-035/3
|
|
8774
|
+
# phase 2 (ADR-037): the repos in configuration order -- the source for the ONE repo
|
|
8775
|
+
# `o` reruns in and `d` names -- and the advisory drift ALREADY recorded by
|
|
8776
|
+
# `spec-drift`. Both are reads of what the ledger holds; `top` still runs nothing.
|
|
8777
|
+
"repos": _top_repos(ledger),
|
|
8778
|
+
"spec_diff": _top_spec_diff(ledger),
|
|
8702
8779
|
# the ONLY real series is the readiness SCORE history; an obligation-count burn-up
|
|
8703
8780
|
# needs new persistence (ADR-035/2), so `kind` is emitted for the TUI to label it a
|
|
8704
8781
|
# score trend and never as a count of closed obligations.
|
|
@@ -20,6 +20,18 @@ TUI never opens the ledger for writing and never builds a curation record, so it
|
|
|
20
20
|
drift from the record shape the engine owns. It records a judgement; it does not promote,
|
|
21
21
|
does not rerun, and never moves DONE (INV-TOP-03).
|
|
22
22
|
|
|
23
|
+
M4 scope (phase 2, ADR-037): `d` opens a READ-ONLY spec↔code drift pane over `spec_diff`, the
|
|
24
|
+
advisory record `qa_ledger.py spec-drift` already left in the ledger -- it measures nothing and
|
|
25
|
+
runs nothing; with no recorded run the pane says so instead of showing a clean board. `o` reruns
|
|
26
|
+
the command THE HUMAN supplied at launch (`--rerun-cmd`, ADR-008 style: the tool never guesses a
|
|
27
|
+
test command) and then lets the engine's own `snapshot` ingest whatever the run produced -- so the
|
|
28
|
+
board still moves only on measured evidence, and the TUI still writes nothing itself. Three spawns
|
|
29
|
+
exist BEYOND THE READ BOUNDARY and no more: `curate` (a verdict), `snapshot` (the ingest) and the
|
|
30
|
+
human's own command, one per keypress, none inside a loop. The read boundary itself -- the one
|
|
31
|
+
`top --json` call in `load_state` -- is a fourth `subprocess.run` in this module and always was;
|
|
32
|
+
counting it among the three read as a false claim to anyone who grepped (1.91.0 blind review), so
|
|
33
|
+
the sentence now says which side of the boundary it counts. Four call sites total, no fifth.
|
|
34
|
+
|
|
23
35
|
Stdlib only. Python 3.8+. Runnable directly or via `python -m uscha_top`.
|
|
24
36
|
"""
|
|
25
37
|
|
|
@@ -44,6 +56,11 @@ MIN_REFRESH = 0.5 # a poll faster than this is a busy loop, not a refresh
|
|
|
44
56
|
|
|
45
57
|
MODE_BOARD = "board"
|
|
46
58
|
MODE_VERDICTS = "verdicts"
|
|
59
|
+
MODE_DIFF = "diff"
|
|
60
|
+
# DIFF geometry: title, rule, the measurement line, the rule under it, the table header, the
|
|
61
|
+
# rule above the status line, the status line, the key hint. The rest is drift rows.
|
|
62
|
+
DIFF_CHROME = 8
|
|
63
|
+
DIFF_HINT = "advisory (ADR-005) -- a stale spec is a conversation, never a gate"
|
|
47
64
|
# VERDICTS geometry: title, rule, the pending line, the rule under the list, the rule under
|
|
48
65
|
# the pane, the status line, the key hint. Everything else is queue rows + the detail pane.
|
|
49
66
|
VERDICT_CHROME = 7
|
|
@@ -62,6 +79,18 @@ VERDICT_COOLDOWN = 0.25
|
|
|
62
79
|
VERDICT_COOLDOWN_MSG = ("verdict recorded -- release the key (the queue advanced; the next "
|
|
63
80
|
"observation is a new judgement)")
|
|
64
81
|
|
|
82
|
+
# `o` (ADR-037, option B). The command is NEVER guessed and never read from config: it is the
|
|
83
|
+
# shell string the human passed at launch, the same discipline `cleanroom --run` follows
|
|
84
|
+
# (ADR-008). Without it the key is inert and says why.
|
|
85
|
+
RERUN_MISSING_MSG = "no rerun command given -- pass --rerun-cmd"
|
|
86
|
+
# The same courtesy a refused verdict key gets: a held `o` is one rerun, and the presses the
|
|
87
|
+
# cooldown eats must SAY they were eaten. A keypress that vanishes silently reads as a dropped
|
|
88
|
+
# input, and the next reflex is to press it again -- the repeat the cooldown exists to stop.
|
|
89
|
+
RERUN_COOLDOWN_MSG = ("rerun in progress or just finished -- release the key (the board "
|
|
90
|
+
"reloaded; press `o` again to run it once more)")
|
|
91
|
+
RERUN_FROZEN_MSG = "--state is a frozen snapshot -- a rerun needs a live ledger"
|
|
92
|
+
RERUN_NO_REPO_MSG = "no repo configured in this ledger -- nothing to rerun and nothing to ingest"
|
|
93
|
+
|
|
65
94
|
# ANSI SGR by obligation state. TRACED and TAGGED deliberately share the UNMEASURED gray:
|
|
66
95
|
# the v0.1 engine has no source for either rung (ADR-032), so they must read as "not
|
|
67
96
|
# measured", never as PASS (INV-TOP-02, AC-T-08).
|
|
@@ -151,6 +180,11 @@ def _pad(text, width):
|
|
|
151
180
|
return str(text) + " " * max(0, width - _dw(text))
|
|
152
181
|
|
|
153
182
|
|
|
183
|
+
def _rjust(text, width):
|
|
184
|
+
"""`str.rjust` measured in columns -- `_pad`'s mirror, for the numeric columns."""
|
|
185
|
+
return " " * max(0, width - _dw(text)) + str(text)
|
|
186
|
+
|
|
187
|
+
|
|
154
188
|
def _fit(text, cols):
|
|
155
189
|
"""One line, never wider than the terminal. Wrapping would break the frame's row
|
|
156
190
|
accounting, so an over-long line is cut and marked.
|
|
@@ -294,6 +328,8 @@ def render(state, size, sel=0, plain=True, mode=MODE_BOARD, status=""):
|
|
|
294
328
|
"""
|
|
295
329
|
if mode == MODE_VERDICTS:
|
|
296
330
|
return _render_verdicts(state, size, sel, plain, status)
|
|
331
|
+
if mode == MODE_DIFF:
|
|
332
|
+
return _render_diff(state, size, sel, plain, status)
|
|
297
333
|
return _render_board(state, size, sel, plain, status)
|
|
298
334
|
|
|
299
335
|
|
|
@@ -386,11 +422,12 @@ def _render_board(state, size, sel, plain, status=""):
|
|
|
386
422
|
out[-1] = _fit("status %s %s" % (MID, _safe(status)), cols)
|
|
387
423
|
for i in range(feed_n):
|
|
388
424
|
out.append(_feed_line(shown[i], cols, plain) if i < len(shown) else "")
|
|
389
|
-
# `[v]
|
|
390
|
-
#
|
|
391
|
-
# labels a live key as future is the same class of stale claim the frames exist to catch
|
|
392
|
-
|
|
393
|
-
|
|
425
|
+
# every key on this line WORKS as of 1.91.0: `[v]` lost its `(M3)` marker when verdicts
|
|
426
|
+
# shipped, and `[d]/[o]` lose their `phase 2` marker here for the same reason. A hint that
|
|
427
|
+
# labels a live key as future is the same class of stale claim the frames exist to catch --
|
|
428
|
+
# and one that labels a dead key as live is the worse half of it.
|
|
429
|
+
out.append("[j/k] move %s [r] reload %s [q] quit %s [v] verdicts %s [d]iff %s [o] rerun"
|
|
430
|
+
% (MID, MID, MID, MID, MID))
|
|
394
431
|
# a coloured line was already fitted BEFORE its escape bytes went in (table rows and
|
|
395
432
|
# feed lines both), and re-fitting it here would count those bytes as visible width --
|
|
396
433
|
# cutting the coloured frame ~9 characters shorter than the plain one it is supposed to
|
|
@@ -552,6 +589,150 @@ def _render_verdicts(state, size, sel, plain, status):
|
|
|
552
589
|
return out[:rows] + [""] * max(0, rows - len(out))
|
|
553
590
|
|
|
554
591
|
|
|
592
|
+
# --------------------------------------------------------------------------- #
|
|
593
|
+
# DIFF mode -- spec <-> code drift, read-only (ADR-037 phase 2) #
|
|
594
|
+
# --------------------------------------------------------------------------- #
|
|
595
|
+
def first_repo(state):
|
|
596
|
+
"""The repo this session acts on: the FIRST configured one, which is the same repo
|
|
597
|
+
`spec_pin` already labels the board with (ADR-032). Returns (name, path) or (None, None).
|
|
598
|
+
|
|
599
|
+
The choice is the engine's, not the TUI's -- `repos[]` arrives in configuration order and
|
|
600
|
+
this only takes the head of it. A multi-repo project therefore reruns and ingests ONE
|
|
601
|
+
repo, the first, and every line that depends on the choice says which repo it picked
|
|
602
|
+
rather than leaving the reader to assume it was all of them."""
|
|
603
|
+
repos = [r for r in (state.get("repos") or []) if isinstance(r, dict) and r.get("name")]
|
|
604
|
+
if not repos:
|
|
605
|
+
return None, None
|
|
606
|
+
return _safe(repos[0]["name"]), _safe(repos[0].get("path") or ".")
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _lag_text(value):
|
|
610
|
+
"""A lag in days, or the em dash when the record carries none. `%g` so a whole number of
|
|
611
|
+
days reads as `60` and a fractional one keeps its tenth -- the record rounds to one
|
|
612
|
+
decimal and this neither adds precision nor drops it."""
|
|
613
|
+
if not isinstance(value, (int, float)):
|
|
614
|
+
return DASH
|
|
615
|
+
return "%g" % value
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _cut_tail(text, width):
|
|
619
|
+
"""The longest SUFFIX of `text` that fits in `width` columns, whole characters only.
|
|
620
|
+
`_cut` read backwards -- same no-split-a-wide-glyph rule, same column arithmetic."""
|
|
621
|
+
if width <= 0:
|
|
622
|
+
return ""
|
|
623
|
+
out, used = [], 0
|
|
624
|
+
for ch in reversed(str(text)):
|
|
625
|
+
w = 0 if unicodedata.combining(ch) else (
|
|
626
|
+
2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1)
|
|
627
|
+
if used + w > width:
|
|
628
|
+
break
|
|
629
|
+
out.append(ch)
|
|
630
|
+
used += w
|
|
631
|
+
return "".join(reversed(out))
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _fit_tail(text, cols):
|
|
635
|
+
"""Like `_fit`, but it keeps the END of the string. Used only for paths: a governed file
|
|
636
|
+
cut at the front still shows the file that moved, cut at the back it shows a directory.
|
|
637
|
+
|
|
638
|
+
Measured and cut in COLUMNS (`_dw`/`_cut_tail`), like `_fit`: `len()` and slicing count
|
|
639
|
+
codepoints, and a CJK path component draws two columns per codepoint -- the DIFF pane was
|
|
640
|
+
the one surface still measuring itself in codepoints after 1.90.0 fixed the board. As
|
|
641
|
+
with `_fit`, the cut may land one column short rather than exactly on `cols - 1` when the
|
|
642
|
+
character at the boundary is wide: one column narrow keeps the frame, one column wide
|
|
643
|
+
does not."""
|
|
644
|
+
text = _safe(text)
|
|
645
|
+
if cols <= 0:
|
|
646
|
+
return ""
|
|
647
|
+
if _dw(text) <= cols:
|
|
648
|
+
return text
|
|
649
|
+
return "…" + _cut_tail(text, cols - 1) if cols > 1 else _cut_tail(text, cols)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _diff_widths(cols):
|
|
653
|
+
"""` <doc> <lag> <code ref>` -- 2 for the gutter, 2+2 for the separators, 6 for LAG."""
|
|
654
|
+
lag = 6
|
|
655
|
+
code = max(10, (cols - 4 - lag - 2) // 2)
|
|
656
|
+
doc = max(10, cols - 4 - lag - 2 - code)
|
|
657
|
+
return doc, lag, code
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _diff_head(diff, state):
|
|
661
|
+
"""The one line that says WHEN this was measured, or that nobody measured it.
|
|
662
|
+
|
|
663
|
+
The honest empty case is the whole point of the pane: with no `spec-drift` record the
|
|
664
|
+
board must not read as "no drift" -- it says there is no run and names the command that
|
|
665
|
+
would produce one (INV-TOP-05, the same rule the feed's empty label follows)."""
|
|
666
|
+
if not diff:
|
|
667
|
+
repo, _path = first_repo(state)
|
|
668
|
+
return ("no spec-drift run recorded -- run `qa_ledger.py spec-drift --repo %s`"
|
|
669
|
+
% (repo or "<repo>"))
|
|
670
|
+
stale = [s for s in (diff.get("stale") or []) if isinstance(s, dict)]
|
|
671
|
+
lag = diff.get("max_lag_days")
|
|
672
|
+
# the COUNT sits before the timestamp on purpose: at the 80-column floor this line is the
|
|
673
|
+
# one that gets cut, and "2 of 4 stale" is the fact the reader came for.
|
|
674
|
+
return ("spec %s code drift %s advisory %s %d of %d doc(s) stale (lag > %s d) %s "
|
|
675
|
+
"measured %s" % ("↔", MID, MID, len(stale), diff.get("docs_total") or 0,
|
|
676
|
+
_num(lag), MID, _safe(diff.get("measured_at")) or DASH))
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _render_diff(state, size, sel, plain, status):
|
|
680
|
+
"""The spec↔code drift pane: `spec_diff` drawn, nothing derived and nothing run.
|
|
681
|
+
|
|
682
|
+
Read-only twice over. `d` never invokes `spec-drift` (that command walks git and WRITES
|
|
683
|
+
its latest-state record; this pane is a projection of that record, ADR-037), and `render`
|
|
684
|
+
performs no I/O at all (ADR-034). What is on screen is what the last real run measured,
|
|
685
|
+
with its own timestamp beside it so an old measurement cannot pass for a fresh one.
|
|
686
|
+
|
|
687
|
+
`sel` and `plain` are accepted and unused: this pane has no cursor (v1 shows the worst
|
|
688
|
+
lags first and NAMES the shortfall rather than scrolling) and no colour of its own -- a
|
|
689
|
+
green/red here would read as a gate, and ADR-005 drift never gates."""
|
|
690
|
+
cols, rows = size
|
|
691
|
+
cols = max(20, int(cols))
|
|
692
|
+
rows = max(DIFF_CHROME + 1, int(rows))
|
|
693
|
+
diff = state.get("spec_diff") if isinstance(state.get("spec_diff"), dict) else None
|
|
694
|
+
doc_w, lag_w, code_w = _diff_widths(cols)
|
|
695
|
+
|
|
696
|
+
out = [_spread("uscha top %s %s %s spec drift"
|
|
697
|
+
% (MID, _safe(state.get("project")) or "(unnamed project)", MID),
|
|
698
|
+
"step #%s" % _safe(_num(state.get("step"))), cols),
|
|
699
|
+
RULE * cols,
|
|
700
|
+
_fit(_diff_head(diff, state), cols),
|
|
701
|
+
RULE * cols,
|
|
702
|
+
" %s %s %s" % (_pad("DOC", doc_w), _rjust("LAG/d", lag_w),
|
|
703
|
+
"A NEWER GOVERNED FILE")]
|
|
704
|
+
|
|
705
|
+
body = []
|
|
706
|
+
stale = [s for s in ((diff or {}).get("stale") or []) if isinstance(s, dict)]
|
|
707
|
+
for s in stale:
|
|
708
|
+
ref = _fit_tail(s.get("code_ref") or DASH, code_w)
|
|
709
|
+
more = s.get("newer_files_total")
|
|
710
|
+
if isinstance(more, int) and more > 1:
|
|
711
|
+
# the row shows ONE file of the N that outran the doc, and says so: a single
|
|
712
|
+
# path with no cardinality beside it reads as "one file changed".
|
|
713
|
+
ref = _fit(ref + " (1 of %d)" % more, code_w)
|
|
714
|
+
body.append(" %s %s %s" % (_pad(_fit(_safe(s.get("doc")) or "?", doc_w), doc_w),
|
|
715
|
+
_rjust(_lag_text(s.get("lag_days")), lag_w), ref))
|
|
716
|
+
if not diff:
|
|
717
|
+
body = [" nothing to show until a spec-drift run is recorded %s `d` reads that "
|
|
718
|
+
"record, it never runs it" % MID]
|
|
719
|
+
elif not stale:
|
|
720
|
+
body = [" no spec document is stale at this lag %s every one reads CLEAN, unmapped "
|
|
721
|
+
"or untracked" % MID]
|
|
722
|
+
|
|
723
|
+
avail = rows - DIFF_CHROME
|
|
724
|
+
if len(body) > avail:
|
|
725
|
+
body = body[:max(0, avail - 1)] + [" %s %d more stale doc(s) do not fit at this size "
|
|
726
|
+
"(worst lag first)" % (DASH, len(body) - avail + 1)]
|
|
727
|
+
out.extend(body[:avail])
|
|
728
|
+
out.extend([""] * max(0, avail - len(body)))
|
|
729
|
+
out.append(RULE * cols)
|
|
730
|
+
out.append(_fit("status %s %s" % (MID, _safe(status) or DIFF_HINT), cols))
|
|
731
|
+
out.append(_fit("[t]/[Esc] back %s [r] reload %s [q] quit" % (MID, MID), cols))
|
|
732
|
+
out = [line if "\x1b" in line else _fit(line, cols) for line in out]
|
|
733
|
+
return out[:rows] + [""] * max(0, rows - len(out))
|
|
734
|
+
|
|
735
|
+
|
|
555
736
|
# --------------------------------------------------------------------------- #
|
|
556
737
|
# state loading (the ONE read boundary -- it shells out, it never re-derives) #
|
|
557
738
|
# --------------------------------------------------------------------------- #
|
|
@@ -795,6 +976,85 @@ def apply_verdict(ob, verdict, args, engine=None):
|
|
|
795
976
|
return False, said or ("curate exited %s -- nothing was recorded" % rc)
|
|
796
977
|
|
|
797
978
|
|
|
979
|
+
def _rerun_call(cmd, cwd):
|
|
980
|
+
"""The human's OWN command, run in the tracked repo's directory (ADR-037, option B).
|
|
981
|
+
|
|
982
|
+
`shell=True` is the decision, not an oversight: what arrives here is the shell string the
|
|
983
|
+
human typed after `--rerun-cmd` (`pytest -q && npm test`), exactly the way `cleanroom
|
|
984
|
+
--run` and `golden-coverage --harness` take theirs -- the engine never decides what to
|
|
985
|
+
run, and neither does this TUI (ADR-008). The trust boundary is the human's own shell,
|
|
986
|
+
which ADR-037 states rather than pretends to mitigate: a misspelt flag runs whatever the
|
|
987
|
+
shell makes of it, in that directory, the same as typing it there.
|
|
988
|
+
|
|
989
|
+
Output is NOT captured: a test suite writes to the terminal the human is watching, and
|
|
990
|
+
swallowing it would replace measured output with a spinner. One function on purpose --
|
|
991
|
+
it is the boundary the suite replaces to assert the command and the ONE call per keypress
|
|
992
|
+
without running anything (AC-T-26). Returns the exit code."""
|
|
993
|
+
return subprocess.run(cmd, shell=True, cwd=cwd).returncode
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _snapshot_call(engine, ledger, repo):
|
|
997
|
+
"""The INGEST, made by the engine's own `snapshot` -- the same subcommand the dev loop
|
|
998
|
+
runs at every pass close. This is what makes `o` honest: the board moves on ingested
|
|
999
|
+
evidence or it does not move at all, and the TUI still builds no record of its own
|
|
1000
|
+
(ADR-033's rule, one more engine subcommand under it -- ADR-037).
|
|
1001
|
+
|
|
1002
|
+
Returns (returncode, the engine's own last line)."""
|
|
1003
|
+
argv = [sys.executable, engine, "snapshot", "--ledger", ledger, "--repo", repo]
|
|
1004
|
+
proc = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
1005
|
+
said = ((proc.stderr or b"").decode("utf-8", "replace").strip().splitlines()
|
|
1006
|
+
or (proc.stdout or b"").decode("utf-8", "replace").strip().splitlines())
|
|
1007
|
+
return proc.returncode, (said[-1] if said else "")
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def rerun_banner(cmd, repo):
|
|
1011
|
+
"""What the board says WHILE the command runs. Pure, so the frame that carries it stays
|
|
1012
|
+
a pure function of its inputs; the loop draws it before it blocks."""
|
|
1013
|
+
return ("rerun: %s %s running in %s (first configured repo) %s verdict keys locked"
|
|
1014
|
+
% (_safe(cmd), MID, _safe(repo), MID))
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def run_rerun(state, args, engine=None):
|
|
1018
|
+
"""One keypress -> the human's command, then the engine's ingest, then a reload upstream.
|
|
1019
|
+
Returns (ran?, the line the status bar shows).
|
|
1020
|
+
|
|
1021
|
+
The order is the decision: the snapshot runs **whether or not the command exited 0**. A
|
|
1022
|
+
red suite is evidence too, and the whole point of `o` is that what lands on the board is
|
|
1023
|
+
what a report says, never what an exit code narrated -- refusing to ingest a red run
|
|
1024
|
+
would leave the board showing the previous, greener measurement (measured red beats
|
|
1025
|
+
narrated green, kit 1.48.1). What a non-zero exit changes is the STATUS LINE, which names
|
|
1026
|
+
it, and nothing else.
|
|
1027
|
+
|
|
1028
|
+
Three refusals come first, none of which spawns anything: no `--rerun-cmd` (the command
|
|
1029
|
+
is the human's to supply, ADR-008), a `--state` frozen snapshot (the ledger on disk is
|
|
1030
|
+
not the one on screen -- the same refusal a verdict gets), and a ledger with no configured
|
|
1031
|
+
repo (there is no cwd to run in and no repo to ingest for)."""
|
|
1032
|
+
cmd = getattr(args, "rerun_cmd", None)
|
|
1033
|
+
if not cmd:
|
|
1034
|
+
return False, RERUN_MISSING_MSG
|
|
1035
|
+
if getattr(args, "state", None):
|
|
1036
|
+
return False, RERUN_FROZEN_MSG
|
|
1037
|
+
repo, path = first_repo(state)
|
|
1038
|
+
if not repo:
|
|
1039
|
+
return False, RERUN_NO_REPO_MSG
|
|
1040
|
+
eng = engine or engine_path()
|
|
1041
|
+
if not eng:
|
|
1042
|
+
return False, "qa_ledger.py not found next to uscha_top.py"
|
|
1043
|
+
cwd = os.path.join(os.path.dirname(os.path.realpath(args.ledger)) or ".", path or ".")
|
|
1044
|
+
if not os.path.isdir(cwd):
|
|
1045
|
+
return False, "repo path '%s' does not exist -- nothing was run" % path
|
|
1046
|
+
code = _rerun_call(cmd, cwd)
|
|
1047
|
+
rc, said = _snapshot_call(eng, args.ledger, repo)
|
|
1048
|
+
tail = said or ("snapshot exited %s" % rc)
|
|
1049
|
+
if rc != 0:
|
|
1050
|
+
return True, ("rerun exit %s %s snapshot FAILED (%s) -- nothing was ingested"
|
|
1051
|
+
% (code, MID, tail))
|
|
1052
|
+
if code != 0:
|
|
1053
|
+
# a red run is still a measurement: it is ingested, and the line says both facts.
|
|
1054
|
+
return True, "rerun exit %s (red) %s ingested: %s" % (code, MID, tail)
|
|
1055
|
+
return True, "rerun exit 0 %s ingested: %s" % (MID, tail)
|
|
1056
|
+
|
|
1057
|
+
|
|
798
1058
|
def after_verdict(sel, count):
|
|
799
1059
|
"""Where the cursor lands once the queue has been re-read: (selection, mode).
|
|
800
1060
|
|
|
@@ -808,7 +1068,26 @@ def after_verdict(sel, count):
|
|
|
808
1068
|
return max(0, min(sel, count - 1)), MODE_VERDICTS
|
|
809
1069
|
|
|
810
1070
|
|
|
811
|
-
def
|
|
1071
|
+
def is_rerun_key(key, mode, cooling=False, rerunning=False):
|
|
1072
|
+
"""Is this keypress a rerun request (ADR-037)? A pure predicate, and deliberately NOT a
|
|
1073
|
+
sixth member of `dispatch_mode`'s tuple: that shape is what M3 measured, and widening a
|
|
1074
|
+
measured contract so it can carry a second action is how a keymap grows a second write
|
|
1075
|
+
path nobody counted. The caller spends a True on exactly one `_rerun_call` + one
|
|
1076
|
+
`_snapshot_call`, never a loop (AC-T-29).
|
|
1077
|
+
|
|
1078
|
+
`o` answers on the BOARD only -- the verdicts queue and the drift pane have their own
|
|
1079
|
+
jobs -- and it is refused while a rerun is in flight or while the 250 ms cooldown after
|
|
1080
|
+
one is still running, so a HELD `o` is one rerun and not a queue of them (the same guard
|
|
1081
|
+
a held verdict key gets, ADR-033)."""
|
|
1082
|
+
return key in ("o", "O") and mode == MODE_BOARD and not cooling and not rerunning
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
# `rerunning=True` is never passed by `_loop`, and that is not an oversight: the rerun is
|
|
1086
|
+
# SYNCHRONOUS (the spawn blocks the loop, and `drain_keys` throws away whatever was typed
|
|
1087
|
+
# meanwhile), so the sync block plus the drain IS the lock -- the flag would have nothing to
|
|
1088
|
+
# guard against. It exists as a MEASURED contract: the predicate is what a future async rerun
|
|
1089
|
+
# would have to honour, and AC-T-27 pins it as a pure function rather than racing a terminal.
|
|
1090
|
+
def dispatch_mode(key, mode, sel, count, cooling=False, rerunning=False):
|
|
812
1091
|
"""The mode machine: key + current mode -> (mode, selection, quit?, reload?, verdict).
|
|
813
1092
|
|
|
814
1093
|
Pure, and the ONE place a keypress becomes a write decision -- `verdict` is a string the
|
|
@@ -821,9 +1100,21 @@ def dispatch_mode(key, mode, sel, count, cooling=False):
|
|
|
821
1100
|
stays pure). While it is true, `p`/`f`/`u` produce NO verdict: a key held down repeats,
|
|
822
1101
|
and the second repeat would judge the observation that just took the cursor's place. Every
|
|
823
1102
|
other key keeps working -- the cooldown blocks writes, not the reader."""
|
|
1103
|
+
if mode == MODE_DIFF:
|
|
1104
|
+
# a read-only pane with a read-only keymap: leave, re-read, or quit. No cursor (the
|
|
1105
|
+
# pane names what does not fit instead of scrolling) and no write of any kind.
|
|
1106
|
+
if key in ("q", "Q", "\x03"):
|
|
1107
|
+
return mode, sel, True, False, None
|
|
1108
|
+
if key in ("t", "T", "\x1b", "d", "D"):
|
|
1109
|
+
return MODE_BOARD, 0, False, False, None
|
|
1110
|
+
if key == "r":
|
|
1111
|
+
return mode, sel, False, True, None
|
|
1112
|
+
return mode, sel, False, False, None
|
|
824
1113
|
if mode != MODE_VERDICTS:
|
|
825
1114
|
if key in ("v", "V"):
|
|
826
1115
|
return MODE_VERDICTS, 0, False, False, None
|
|
1116
|
+
if key in ("d", "D"):
|
|
1117
|
+
return MODE_DIFF, 0, False, False, None
|
|
827
1118
|
sel, quit_now, reload_now = dispatch(key, sel, count)
|
|
828
1119
|
return MODE_BOARD, sel, quit_now, reload_now, None
|
|
829
1120
|
if key in ("q", "Q", "\x03"):
|
|
@@ -840,7 +1131,11 @@ def dispatch_mode(key, mode, sel, count, cooling=False):
|
|
|
840
1131
|
# an empty queue produces NO verdict: there is nothing selected to judge, and a
|
|
841
1132
|
# keypress that writes anyway would be a verdict the human never aimed at an OBS.
|
|
842
1133
|
# Neither does a queue still cooling from the last one.
|
|
843
|
-
|
|
1134
|
+
# `rerunning` is the same refusal for a different reason (ADR-037): while a rerun is
|
|
1135
|
+
# in flight the queue on screen was read BEFORE it, and a verdict recorded against a
|
|
1136
|
+
# queue the ingest is about to move is a judgement aimed at the wrong observation.
|
|
1137
|
+
return mode, sel, False, False, (VERDICTS[key] if (count and not cooling
|
|
1138
|
+
and not rerunning) else None)
|
|
844
1139
|
if len(str(key)) == 1 and key in "123456789":
|
|
845
1140
|
n = int(key) - 1
|
|
846
1141
|
return mode, (n if n < count else sel), False, False, None
|
|
@@ -904,6 +1199,25 @@ def _apply_and_advance(state, args, queue, cur, verdict):
|
|
|
904
1199
|
return state, cur, mode, status, True
|
|
905
1200
|
|
|
906
1201
|
|
|
1202
|
+
def _rerun_and_reload(state, args):
|
|
1203
|
+
"""ONE keypress -> ONE command -> ONE `snapshot` -> re-read. Returns (state, status).
|
|
1204
|
+
|
|
1205
|
+
A function of its own for the same structural reason `_apply_and_advance` is one, and the
|
|
1206
|
+
suite asserts it the same way (AC-T-29): the module's single call to `run_rerun` must have
|
|
1207
|
+
no `for`/`while` above it, so no later edit can quietly turn one keypress into a pass over
|
|
1208
|
+
the repos. The re-read afterwards is what makes the new measurement visible; DONE moves
|
|
1209
|
+
here or nowhere, because the ingest is the only thing that can move it (INV-TOP-03).
|
|
1210
|
+
|
|
1211
|
+
The input buffer is drained whether anything ran or not: a suite that takes a minute is
|
|
1212
|
+
exactly when a human types, and those keystrokes belong to the terminal they were typed
|
|
1213
|
+
into, not to the board that comes back."""
|
|
1214
|
+
ran, status = run_rerun(state, args)
|
|
1215
|
+
drain_keys()
|
|
1216
|
+
if not ran:
|
|
1217
|
+
return state, status
|
|
1218
|
+
return _reload(state, args), status
|
|
1219
|
+
|
|
1220
|
+
|
|
907
1221
|
def _loop(state, args):
|
|
908
1222
|
sel = 0 # the board's cursor
|
|
909
1223
|
vsel = 0 # the verdict queue's cursor, kept apart from it
|
|
@@ -947,6 +1261,26 @@ def _loop(state, args):
|
|
|
947
1261
|
cooldown_until = time.time() + VERDICT_COOLDOWN
|
|
948
1262
|
if wrote:
|
|
949
1263
|
_fresh, seen = _changed(paths, seen)
|
|
1264
|
+
elif is_rerun_key(key, mode, cooling=cooling):
|
|
1265
|
+
if getattr(args, "rerun_cmd", None):
|
|
1266
|
+
# the frame the human watches WHILE the command runs, drawn before
|
|
1267
|
+
# the spawn because the spawn blocks this loop until it returns.
|
|
1268
|
+
# That synchronous shape is also why the verdict lock is measured on
|
|
1269
|
+
# `dispatch_mode(..., rerunning=True)` and not raced against a
|
|
1270
|
+
# terminal (AC-T-27): while the suite runs, no key is read at all --
|
|
1271
|
+
# what is typed lands in the buffer and the drain throws it away.
|
|
1272
|
+
sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(render(
|
|
1273
|
+
state, terminal_size(args.cols, args.rows), sel=sel, plain=False,
|
|
1274
|
+
mode=mode, status=rerun_banner(args.rerun_cmd,
|
|
1275
|
+
first_repo(state)[0] or "?"))))
|
|
1276
|
+
sys.stdout.flush()
|
|
1277
|
+
state, status = _rerun_and_reload(state, args)
|
|
1278
|
+
# the same 250 ms a verdict gets, for the same reason: a held `o` must be
|
|
1279
|
+
# one rerun, not a queue of them.
|
|
1280
|
+
cooldown_until = time.time() + VERDICT_COOLDOWN
|
|
1281
|
+
_fresh, seen = _changed(paths, seen)
|
|
1282
|
+
elif key in ("o", "O") and cooling and mode == MODE_BOARD:
|
|
1283
|
+
status = RERUN_COOLDOWN_MSG
|
|
950
1284
|
elif key in VERDICTS and cooling:
|
|
951
1285
|
# the key WAS a verdict and it was refused: say why. A keypress that
|
|
952
1286
|
# vanishes silently reads as a dropped input, and the next reflex is to
|
|
@@ -997,6 +1331,13 @@ def build_parser():
|
|
|
997
1331
|
"set, `curate`'s own default applies). The person pressing the "
|
|
998
1332
|
"key is the author of the judgement -- the TUI never invents a "
|
|
999
1333
|
"name for it")
|
|
1334
|
+
parser.add_argument("--rerun-cmd", default=None,
|
|
1335
|
+
help="the shell command `o` reruns, in the first configured repo's "
|
|
1336
|
+
"directory (e.g. \"pytest -q\"). The tool NEVER guesses it and "
|
|
1337
|
+
"never reads it from config (ADR-008/037): without this flag "
|
|
1338
|
+
"`o` is inert and says so. After the command, the engine's own "
|
|
1339
|
+
"`snapshot` ingests the report -- on a red run too, because a "
|
|
1340
|
+
"red measurement is still a measurement")
|
|
1000
1341
|
parser.add_argument("--cols", type=int, default=None)
|
|
1001
1342
|
parser.add_argument("--rows", type=int, default=None)
|
|
1002
1343
|
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.91.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.91.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`,
|
|
@@ -591,11 +591,7 @@ python3 $QL simplicity-check --diff changes.diff --json # consumed by usc
|
|
|
591
591
|
|
|
592
592
|
## Ledger subcommands
|
|
593
593
|
|
|
594
|
-
`
|
|
595
|
-
converged - oscillation - escalate - resolve-escalation - log-gate - flag-blocker -
|
|
596
|
-
production-finding - spec-doubt - spec-change-request - regression-check - summary - readiness -
|
|
597
|
-
execution-policy - dashboard - rebuild - simplicity-check - waste-check - pit-check - gate-check -
|
|
598
|
-
spec-check - golden-diff` - the exact current `qa_ledger.py` parser surface; each supports `--help`.
|
|
594
|
+
`bench - bench-curate - bench-r2 - bench-roundtrip - bootstrap-oracle - bootstrap-variance - check-coverage - cleanroom - compile-ingest - compile-validate - converged - curate - curation-check - dashboard - discover - doctor - escalate - execution-policy - facts - fastpath-eval - fidelity - flag-blocker - gate-check - golden-coverage - golden-diff - ingest-gate - init - ir-extract - ir-render - lang-compare - log-gate - log-step - oscillation - phase - pit-check - production-finding - promote - readiness - rebuild - regression-check - resolve-escalation - roundtrip - rubric-ingest - simplicity-check - snapshot - spec-change-request - spec-check - spec-doubt - spec-drift - summary - top - waste-check` - the exact current `qa_ledger.py` parser surface (52 subcommands, derived from `SYSTEM-FACTS.json`, itself introspected from `build_parser()`); each supports `--help`.
|
|
599
595
|
|
|
600
596
|
The **fact gates** (golden-diff, gate-check, pit-check, simplicity) are PERSISTED with
|
|
601
597
|
`log-gate`: a fail blocks convergence and caps readiness ≤65 via the ledger. A CONSTITUTION
|
package/uscha-kit/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
uscha-kit 1.
|
|
1
|
+
uscha-kit 1.91.0
|
|
@@ -842,6 +842,10 @@ def cmd_top(args):
|
|
|
842
842
|
# and uscha_top.py falls back to $USERNAME/$USER, then to curate's own default.
|
|
843
843
|
if getattr(args, "human", None):
|
|
844
844
|
cmd += ["--human", args.human]
|
|
845
|
+
# and so does the rerun command (1.91.0, ADR-037): `o` runs what the HUMAN passed at
|
|
846
|
+
# launch and nothing else -- the launcher forwards the string, it never supplies one.
|
|
847
|
+
if getattr(args, "rerun_cmd", None):
|
|
848
|
+
cmd += ["--rerun-cmd", args.rerun_cmd]
|
|
845
849
|
rc = subprocess.call(cmd)
|
|
846
850
|
if rc:
|
|
847
851
|
raise SystemExit(rc)
|
|
@@ -1051,6 +1055,7 @@ def build_parser():
|
|
|
1051
1055
|
top.add_argument("--once", action="store_true", help="print one plain frame and exit (implied without a TTY)")
|
|
1052
1056
|
top.add_argument("--refresh", type=float, default=2.0, help="seconds between mtime polls of the ledger (default: 2, floor 0.5); `r` still forces a re-read")
|
|
1053
1057
|
top.add_argument("--human", default=None, help="who is at the keyboard: the name recorded on every verdict this session writes (default: $USERNAME/$USER, then `curate`'s own default)")
|
|
1058
|
+
top.add_argument("--rerun-cmd", default=None, help="the shell command `o` reruns in the first configured repo (e.g. \"pytest -q\"); never guessed and never read from config -- without it `o` is inert. The engine's own `snapshot` ingests the report afterwards, red runs included")
|
|
1054
1059
|
top.add_argument("--json", action="store_true", help="print the engine's read-only `top --json` contract instead of rendering it")
|
|
1055
1060
|
top.set_defaults(func=cmd_top)
|
|
1056
1061
|
return parser
|
|
@@ -8567,6 +8567,78 @@ def _top_events(ledger, limit=TOP_EVENTS_TAIL):
|
|
|
8567
8567
|
return events[:max(0, int(limit))]
|
|
8568
8568
|
|
|
8569
8569
|
|
|
8570
|
+
def _top_repos(ledger):
|
|
8571
|
+
"""The configured repos, name and configured path, in configuration order (phase 2).
|
|
8572
|
+
|
|
8573
|
+
Two things the TUI must not decide for itself now have a source: WHICH repo a rerun runs
|
|
8574
|
+
in and ingests for (ADR-037 picks the first configured one, exactly as `_top_spec_pin`
|
|
8575
|
+
picks the sha it labels), and WHICH repo the `d` pane names when it tells the reader how
|
|
8576
|
+
to produce the spec-drift run that is missing. `path` is the path as CONFIGURED --
|
|
8577
|
+
relative to the ledger, never resolved here: an absolute machine path in the contract is
|
|
8578
|
+
a frozen state nobody else can render (the golden frames are files in a repo)."""
|
|
8579
|
+
out = []
|
|
8580
|
+
for r in ((ledger.get("config", {}) or {}).get("repos") or []):
|
|
8581
|
+
if isinstance(r, dict) and r.get("name"):
|
|
8582
|
+
out.append({"name": _top_clean(r["name"]), "path": _top_clean(r.get("path", "."))})
|
|
8583
|
+
return out
|
|
8584
|
+
|
|
8585
|
+
|
|
8586
|
+
def _top_spec_diff(ledger):
|
|
8587
|
+
"""The advisory spec↔code drift the ledger ALREADY carries, projected for `d` (ADR-037).
|
|
8588
|
+
|
|
8589
|
+
It measures NOTHING: `spec-drift` (ADR-005) is the only command that walks git for this,
|
|
8590
|
+
and `top` is read-only by contract (ADR-032) -- so this reads `ledger["spec_drift"]`, the
|
|
8591
|
+
latest-state record that command leaves behind, and nothing else. **No recorded run ->
|
|
8592
|
+
`null`**, which the TUI renders as "no spec-drift run recorded", never as "no drift":
|
|
8593
|
+
"nobody measured" and "nothing is stale" are different statements and only one of them is
|
|
8594
|
+
ever free (INV-TOP-05).
|
|
8595
|
+
|
|
8596
|
+
Only `SPEC_STALE` rows travel: CLEAN/UNMAPPED/UNTRACKED/NO-CODE are the four ways a doc
|
|
8597
|
+
is NOT drifting, and `docs_total` keeps the denominator visible beside the count so the
|
|
8598
|
+
pane can say `3 of 21`. `code_ref` is ONE of the governed files that outran the doc --
|
|
8599
|
+
the record stores a capped, alphabetically sorted list and no per-file dates, so it is
|
|
8600
|
+
"a newer file", never "the newest one" (under-claim; `newer_files_total` carries the
|
|
8601
|
+
real cardinality)."""
|
|
8602
|
+
rec = ledger.get("spec_drift")
|
|
8603
|
+
if not isinstance(rec, dict):
|
|
8604
|
+
return None
|
|
8605
|
+
results = rec.get("results")
|
|
8606
|
+
results = results if isinstance(results, list) else []
|
|
8607
|
+
docs = [r for r in results if isinstance(r, dict)]
|
|
8608
|
+
stale = []
|
|
8609
|
+
for r in docs:
|
|
8610
|
+
if r.get("verdict") != "SPEC_STALE":
|
|
8611
|
+
continue
|
|
8612
|
+
# `newer_files` is a LIST in the record `spec-drift` writes, but the ledger is JSON on
|
|
8613
|
+
# disk: a hand edit can leave a string there, and a string is iterable -- the old
|
|
8614
|
+
# comprehension would have walked its characters and named `"n"` as the governed file
|
|
8615
|
+
# that outran the doc. A non-list is no evidence, so it yields no code_ref and 0.
|
|
8616
|
+
nf = r.get("newer_files")
|
|
8617
|
+
newer = [f for f in nf if isinstance(f, str)] if isinstance(nf, list) else []
|
|
8618
|
+
lag = r.get("lag_days_actual")
|
|
8619
|
+
total = r.get("newer_files_total")
|
|
8620
|
+
stale.append({
|
|
8621
|
+
"doc": _top_clean(r.get("file") or "?"),
|
|
8622
|
+
"lag_days": lag if isinstance(lag, (int, float)) else None,
|
|
8623
|
+
"code_ref": _top_clean(newer[0]) if newer else None,
|
|
8624
|
+
"newer_files_total": total if isinstance(total, int) else len(newer),
|
|
8625
|
+
"spec_committed_at": (_top_clean(r["spec_committed_at"])
|
|
8626
|
+
if r.get("spec_committed_at") else None),
|
|
8627
|
+
"newest_governed_at": (_top_clean(r["newest_governed_at"])
|
|
8628
|
+
if r.get("newest_governed_at") else None)})
|
|
8629
|
+
# worst lag first, the doc name as the tie-break: deterministic given the record, which
|
|
8630
|
+
# is what lets a golden frame be the oracle for this pane too (ADR-034).
|
|
8631
|
+
stale.sort(key=lambda s: (-(s["lag_days"] or 0), s["doc"]))
|
|
8632
|
+
lag_days = rec.get("max_lag_days")
|
|
8633
|
+
return {"measured_at": _top_clean(rec["at"]) if rec.get("at") else None,
|
|
8634
|
+
"repo": _top_clean(rec["repo"]) if rec.get("repo") else None,
|
|
8635
|
+
"max_lag_days": lag_days if isinstance(lag_days, int) else None,
|
|
8636
|
+
"docs_total": len(docs),
|
|
8637
|
+
"stale": stale,
|
|
8638
|
+
"advisory": True, # ADR-005: this never gates, here or anywhere
|
|
8639
|
+
"source": "spec-drift"}
|
|
8640
|
+
|
|
8641
|
+
|
|
8570
8642
|
def cmd_top(args):
|
|
8571
8643
|
"""`uscha top` — the WHOLE projection of the ledger as one read-only JSON (ADR-032).
|
|
8572
8644
|
|
|
@@ -8699,6 +8771,11 @@ def cmd_top(args):
|
|
|
8699
8771
|
"medians": {"verdict_min": None, "loop_min": _top_loop_median_min(ledger)},
|
|
8700
8772
|
"checks": _top_checks(ledger),
|
|
8701
8773
|
"drift_pct": None, # spec_drift is per-file; an aggregate is ADR-035/3
|
|
8774
|
+
# phase 2 (ADR-037): the repos in configuration order -- the source for the ONE repo
|
|
8775
|
+
# `o` reruns in and `d` names -- and the advisory drift ALREADY recorded by
|
|
8776
|
+
# `spec-drift`. Both are reads of what the ledger holds; `top` still runs nothing.
|
|
8777
|
+
"repos": _top_repos(ledger),
|
|
8778
|
+
"spec_diff": _top_spec_diff(ledger),
|
|
8702
8779
|
# the ONLY real series is the readiness SCORE history; an obligation-count burn-up
|
|
8703
8780
|
# needs new persistence (ADR-035/2), so `kind` is emitted for the TUI to label it a
|
|
8704
8781
|
# score trend and never as a count of closed obligations.
|
|
@@ -20,6 +20,18 @@ TUI never opens the ledger for writing and never builds a curation record, so it
|
|
|
20
20
|
drift from the record shape the engine owns. It records a judgement; it does not promote,
|
|
21
21
|
does not rerun, and never moves DONE (INV-TOP-03).
|
|
22
22
|
|
|
23
|
+
M4 scope (phase 2, ADR-037): `d` opens a READ-ONLY spec↔code drift pane over `spec_diff`, the
|
|
24
|
+
advisory record `qa_ledger.py spec-drift` already left in the ledger -- it measures nothing and
|
|
25
|
+
runs nothing; with no recorded run the pane says so instead of showing a clean board. `o` reruns
|
|
26
|
+
the command THE HUMAN supplied at launch (`--rerun-cmd`, ADR-008 style: the tool never guesses a
|
|
27
|
+
test command) and then lets the engine's own `snapshot` ingest whatever the run produced -- so the
|
|
28
|
+
board still moves only on measured evidence, and the TUI still writes nothing itself. Three spawns
|
|
29
|
+
exist BEYOND THE READ BOUNDARY and no more: `curate` (a verdict), `snapshot` (the ingest) and the
|
|
30
|
+
human's own command, one per keypress, none inside a loop. The read boundary itself -- the one
|
|
31
|
+
`top --json` call in `load_state` -- is a fourth `subprocess.run` in this module and always was;
|
|
32
|
+
counting it among the three read as a false claim to anyone who grepped (1.91.0 blind review), so
|
|
33
|
+
the sentence now says which side of the boundary it counts. Four call sites total, no fifth.
|
|
34
|
+
|
|
23
35
|
Stdlib only. Python 3.8+. Runnable directly or via `python -m uscha_top`.
|
|
24
36
|
"""
|
|
25
37
|
|
|
@@ -44,6 +56,11 @@ MIN_REFRESH = 0.5 # a poll faster than this is a busy loop, not a refresh
|
|
|
44
56
|
|
|
45
57
|
MODE_BOARD = "board"
|
|
46
58
|
MODE_VERDICTS = "verdicts"
|
|
59
|
+
MODE_DIFF = "diff"
|
|
60
|
+
# DIFF geometry: title, rule, the measurement line, the rule under it, the table header, the
|
|
61
|
+
# rule above the status line, the status line, the key hint. The rest is drift rows.
|
|
62
|
+
DIFF_CHROME = 8
|
|
63
|
+
DIFF_HINT = "advisory (ADR-005) -- a stale spec is a conversation, never a gate"
|
|
47
64
|
# VERDICTS geometry: title, rule, the pending line, the rule under the list, the rule under
|
|
48
65
|
# the pane, the status line, the key hint. Everything else is queue rows + the detail pane.
|
|
49
66
|
VERDICT_CHROME = 7
|
|
@@ -62,6 +79,18 @@ VERDICT_COOLDOWN = 0.25
|
|
|
62
79
|
VERDICT_COOLDOWN_MSG = ("verdict recorded -- release the key (the queue advanced; the next "
|
|
63
80
|
"observation is a new judgement)")
|
|
64
81
|
|
|
82
|
+
# `o` (ADR-037, option B). The command is NEVER guessed and never read from config: it is the
|
|
83
|
+
# shell string the human passed at launch, the same discipline `cleanroom --run` follows
|
|
84
|
+
# (ADR-008). Without it the key is inert and says why.
|
|
85
|
+
RERUN_MISSING_MSG = "no rerun command given -- pass --rerun-cmd"
|
|
86
|
+
# The same courtesy a refused verdict key gets: a held `o` is one rerun, and the presses the
|
|
87
|
+
# cooldown eats must SAY they were eaten. A keypress that vanishes silently reads as a dropped
|
|
88
|
+
# input, and the next reflex is to press it again -- the repeat the cooldown exists to stop.
|
|
89
|
+
RERUN_COOLDOWN_MSG = ("rerun in progress or just finished -- release the key (the board "
|
|
90
|
+
"reloaded; press `o` again to run it once more)")
|
|
91
|
+
RERUN_FROZEN_MSG = "--state is a frozen snapshot -- a rerun needs a live ledger"
|
|
92
|
+
RERUN_NO_REPO_MSG = "no repo configured in this ledger -- nothing to rerun and nothing to ingest"
|
|
93
|
+
|
|
65
94
|
# ANSI SGR by obligation state. TRACED and TAGGED deliberately share the UNMEASURED gray:
|
|
66
95
|
# the v0.1 engine has no source for either rung (ADR-032), so they must read as "not
|
|
67
96
|
# measured", never as PASS (INV-TOP-02, AC-T-08).
|
|
@@ -151,6 +180,11 @@ def _pad(text, width):
|
|
|
151
180
|
return str(text) + " " * max(0, width - _dw(text))
|
|
152
181
|
|
|
153
182
|
|
|
183
|
+
def _rjust(text, width):
|
|
184
|
+
"""`str.rjust` measured in columns -- `_pad`'s mirror, for the numeric columns."""
|
|
185
|
+
return " " * max(0, width - _dw(text)) + str(text)
|
|
186
|
+
|
|
187
|
+
|
|
154
188
|
def _fit(text, cols):
|
|
155
189
|
"""One line, never wider than the terminal. Wrapping would break the frame's row
|
|
156
190
|
accounting, so an over-long line is cut and marked.
|
|
@@ -294,6 +328,8 @@ def render(state, size, sel=0, plain=True, mode=MODE_BOARD, status=""):
|
|
|
294
328
|
"""
|
|
295
329
|
if mode == MODE_VERDICTS:
|
|
296
330
|
return _render_verdicts(state, size, sel, plain, status)
|
|
331
|
+
if mode == MODE_DIFF:
|
|
332
|
+
return _render_diff(state, size, sel, plain, status)
|
|
297
333
|
return _render_board(state, size, sel, plain, status)
|
|
298
334
|
|
|
299
335
|
|
|
@@ -386,11 +422,12 @@ def _render_board(state, size, sel, plain, status=""):
|
|
|
386
422
|
out[-1] = _fit("status %s %s" % (MID, _safe(status)), cols)
|
|
387
423
|
for i in range(feed_n):
|
|
388
424
|
out.append(_feed_line(shown[i], cols, plain) if i < len(shown) else "")
|
|
389
|
-
# `[v]
|
|
390
|
-
#
|
|
391
|
-
# labels a live key as future is the same class of stale claim the frames exist to catch
|
|
392
|
-
|
|
393
|
-
|
|
425
|
+
# every key on this line WORKS as of 1.91.0: `[v]` lost its `(M3)` marker when verdicts
|
|
426
|
+
# shipped, and `[d]/[o]` lose their `phase 2` marker here for the same reason. A hint that
|
|
427
|
+
# labels a live key as future is the same class of stale claim the frames exist to catch --
|
|
428
|
+
# and one that labels a dead key as live is the worse half of it.
|
|
429
|
+
out.append("[j/k] move %s [r] reload %s [q] quit %s [v] verdicts %s [d]iff %s [o] rerun"
|
|
430
|
+
% (MID, MID, MID, MID, MID))
|
|
394
431
|
# a coloured line was already fitted BEFORE its escape bytes went in (table rows and
|
|
395
432
|
# feed lines both), and re-fitting it here would count those bytes as visible width --
|
|
396
433
|
# cutting the coloured frame ~9 characters shorter than the plain one it is supposed to
|
|
@@ -552,6 +589,150 @@ def _render_verdicts(state, size, sel, plain, status):
|
|
|
552
589
|
return out[:rows] + [""] * max(0, rows - len(out))
|
|
553
590
|
|
|
554
591
|
|
|
592
|
+
# --------------------------------------------------------------------------- #
|
|
593
|
+
# DIFF mode -- spec <-> code drift, read-only (ADR-037 phase 2) #
|
|
594
|
+
# --------------------------------------------------------------------------- #
|
|
595
|
+
def first_repo(state):
|
|
596
|
+
"""The repo this session acts on: the FIRST configured one, which is the same repo
|
|
597
|
+
`spec_pin` already labels the board with (ADR-032). Returns (name, path) or (None, None).
|
|
598
|
+
|
|
599
|
+
The choice is the engine's, not the TUI's -- `repos[]` arrives in configuration order and
|
|
600
|
+
this only takes the head of it. A multi-repo project therefore reruns and ingests ONE
|
|
601
|
+
repo, the first, and every line that depends on the choice says which repo it picked
|
|
602
|
+
rather than leaving the reader to assume it was all of them."""
|
|
603
|
+
repos = [r for r in (state.get("repos") or []) if isinstance(r, dict) and r.get("name")]
|
|
604
|
+
if not repos:
|
|
605
|
+
return None, None
|
|
606
|
+
return _safe(repos[0]["name"]), _safe(repos[0].get("path") or ".")
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _lag_text(value):
|
|
610
|
+
"""A lag in days, or the em dash when the record carries none. `%g` so a whole number of
|
|
611
|
+
days reads as `60` and a fractional one keeps its tenth -- the record rounds to one
|
|
612
|
+
decimal and this neither adds precision nor drops it."""
|
|
613
|
+
if not isinstance(value, (int, float)):
|
|
614
|
+
return DASH
|
|
615
|
+
return "%g" % value
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _cut_tail(text, width):
|
|
619
|
+
"""The longest SUFFIX of `text` that fits in `width` columns, whole characters only.
|
|
620
|
+
`_cut` read backwards -- same no-split-a-wide-glyph rule, same column arithmetic."""
|
|
621
|
+
if width <= 0:
|
|
622
|
+
return ""
|
|
623
|
+
out, used = [], 0
|
|
624
|
+
for ch in reversed(str(text)):
|
|
625
|
+
w = 0 if unicodedata.combining(ch) else (
|
|
626
|
+
2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1)
|
|
627
|
+
if used + w > width:
|
|
628
|
+
break
|
|
629
|
+
out.append(ch)
|
|
630
|
+
used += w
|
|
631
|
+
return "".join(reversed(out))
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _fit_tail(text, cols):
|
|
635
|
+
"""Like `_fit`, but it keeps the END of the string. Used only for paths: a governed file
|
|
636
|
+
cut at the front still shows the file that moved, cut at the back it shows a directory.
|
|
637
|
+
|
|
638
|
+
Measured and cut in COLUMNS (`_dw`/`_cut_tail`), like `_fit`: `len()` and slicing count
|
|
639
|
+
codepoints, and a CJK path component draws two columns per codepoint -- the DIFF pane was
|
|
640
|
+
the one surface still measuring itself in codepoints after 1.90.0 fixed the board. As
|
|
641
|
+
with `_fit`, the cut may land one column short rather than exactly on `cols - 1` when the
|
|
642
|
+
character at the boundary is wide: one column narrow keeps the frame, one column wide
|
|
643
|
+
does not."""
|
|
644
|
+
text = _safe(text)
|
|
645
|
+
if cols <= 0:
|
|
646
|
+
return ""
|
|
647
|
+
if _dw(text) <= cols:
|
|
648
|
+
return text
|
|
649
|
+
return "…" + _cut_tail(text, cols - 1) if cols > 1 else _cut_tail(text, cols)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _diff_widths(cols):
|
|
653
|
+
"""` <doc> <lag> <code ref>` -- 2 for the gutter, 2+2 for the separators, 6 for LAG."""
|
|
654
|
+
lag = 6
|
|
655
|
+
code = max(10, (cols - 4 - lag - 2) // 2)
|
|
656
|
+
doc = max(10, cols - 4 - lag - 2 - code)
|
|
657
|
+
return doc, lag, code
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _diff_head(diff, state):
|
|
661
|
+
"""The one line that says WHEN this was measured, or that nobody measured it.
|
|
662
|
+
|
|
663
|
+
The honest empty case is the whole point of the pane: with no `spec-drift` record the
|
|
664
|
+
board must not read as "no drift" -- it says there is no run and names the command that
|
|
665
|
+
would produce one (INV-TOP-05, the same rule the feed's empty label follows)."""
|
|
666
|
+
if not diff:
|
|
667
|
+
repo, _path = first_repo(state)
|
|
668
|
+
return ("no spec-drift run recorded -- run `qa_ledger.py spec-drift --repo %s`"
|
|
669
|
+
% (repo or "<repo>"))
|
|
670
|
+
stale = [s for s in (diff.get("stale") or []) if isinstance(s, dict)]
|
|
671
|
+
lag = diff.get("max_lag_days")
|
|
672
|
+
# the COUNT sits before the timestamp on purpose: at the 80-column floor this line is the
|
|
673
|
+
# one that gets cut, and "2 of 4 stale" is the fact the reader came for.
|
|
674
|
+
return ("spec %s code drift %s advisory %s %d of %d doc(s) stale (lag > %s d) %s "
|
|
675
|
+
"measured %s" % ("↔", MID, MID, len(stale), diff.get("docs_total") or 0,
|
|
676
|
+
_num(lag), MID, _safe(diff.get("measured_at")) or DASH))
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _render_diff(state, size, sel, plain, status):
|
|
680
|
+
"""The spec↔code drift pane: `spec_diff` drawn, nothing derived and nothing run.
|
|
681
|
+
|
|
682
|
+
Read-only twice over. `d` never invokes `spec-drift` (that command walks git and WRITES
|
|
683
|
+
its latest-state record; this pane is a projection of that record, ADR-037), and `render`
|
|
684
|
+
performs no I/O at all (ADR-034). What is on screen is what the last real run measured,
|
|
685
|
+
with its own timestamp beside it so an old measurement cannot pass for a fresh one.
|
|
686
|
+
|
|
687
|
+
`sel` and `plain` are accepted and unused: this pane has no cursor (v1 shows the worst
|
|
688
|
+
lags first and NAMES the shortfall rather than scrolling) and no colour of its own -- a
|
|
689
|
+
green/red here would read as a gate, and ADR-005 drift never gates."""
|
|
690
|
+
cols, rows = size
|
|
691
|
+
cols = max(20, int(cols))
|
|
692
|
+
rows = max(DIFF_CHROME + 1, int(rows))
|
|
693
|
+
diff = state.get("spec_diff") if isinstance(state.get("spec_diff"), dict) else None
|
|
694
|
+
doc_w, lag_w, code_w = _diff_widths(cols)
|
|
695
|
+
|
|
696
|
+
out = [_spread("uscha top %s %s %s spec drift"
|
|
697
|
+
% (MID, _safe(state.get("project")) or "(unnamed project)", MID),
|
|
698
|
+
"step #%s" % _safe(_num(state.get("step"))), cols),
|
|
699
|
+
RULE * cols,
|
|
700
|
+
_fit(_diff_head(diff, state), cols),
|
|
701
|
+
RULE * cols,
|
|
702
|
+
" %s %s %s" % (_pad("DOC", doc_w), _rjust("LAG/d", lag_w),
|
|
703
|
+
"A NEWER GOVERNED FILE")]
|
|
704
|
+
|
|
705
|
+
body = []
|
|
706
|
+
stale = [s for s in ((diff or {}).get("stale") or []) if isinstance(s, dict)]
|
|
707
|
+
for s in stale:
|
|
708
|
+
ref = _fit_tail(s.get("code_ref") or DASH, code_w)
|
|
709
|
+
more = s.get("newer_files_total")
|
|
710
|
+
if isinstance(more, int) and more > 1:
|
|
711
|
+
# the row shows ONE file of the N that outran the doc, and says so: a single
|
|
712
|
+
# path with no cardinality beside it reads as "one file changed".
|
|
713
|
+
ref = _fit(ref + " (1 of %d)" % more, code_w)
|
|
714
|
+
body.append(" %s %s %s" % (_pad(_fit(_safe(s.get("doc")) or "?", doc_w), doc_w),
|
|
715
|
+
_rjust(_lag_text(s.get("lag_days")), lag_w), ref))
|
|
716
|
+
if not diff:
|
|
717
|
+
body = [" nothing to show until a spec-drift run is recorded %s `d` reads that "
|
|
718
|
+
"record, it never runs it" % MID]
|
|
719
|
+
elif not stale:
|
|
720
|
+
body = [" no spec document is stale at this lag %s every one reads CLEAN, unmapped "
|
|
721
|
+
"or untracked" % MID]
|
|
722
|
+
|
|
723
|
+
avail = rows - DIFF_CHROME
|
|
724
|
+
if len(body) > avail:
|
|
725
|
+
body = body[:max(0, avail - 1)] + [" %s %d more stale doc(s) do not fit at this size "
|
|
726
|
+
"(worst lag first)" % (DASH, len(body) - avail + 1)]
|
|
727
|
+
out.extend(body[:avail])
|
|
728
|
+
out.extend([""] * max(0, avail - len(body)))
|
|
729
|
+
out.append(RULE * cols)
|
|
730
|
+
out.append(_fit("status %s %s" % (MID, _safe(status) or DIFF_HINT), cols))
|
|
731
|
+
out.append(_fit("[t]/[Esc] back %s [r] reload %s [q] quit" % (MID, MID), cols))
|
|
732
|
+
out = [line if "\x1b" in line else _fit(line, cols) for line in out]
|
|
733
|
+
return out[:rows] + [""] * max(0, rows - len(out))
|
|
734
|
+
|
|
735
|
+
|
|
555
736
|
# --------------------------------------------------------------------------- #
|
|
556
737
|
# state loading (the ONE read boundary -- it shells out, it never re-derives) #
|
|
557
738
|
# --------------------------------------------------------------------------- #
|
|
@@ -795,6 +976,85 @@ def apply_verdict(ob, verdict, args, engine=None):
|
|
|
795
976
|
return False, said or ("curate exited %s -- nothing was recorded" % rc)
|
|
796
977
|
|
|
797
978
|
|
|
979
|
+
def _rerun_call(cmd, cwd):
|
|
980
|
+
"""The human's OWN command, run in the tracked repo's directory (ADR-037, option B).
|
|
981
|
+
|
|
982
|
+
`shell=True` is the decision, not an oversight: what arrives here is the shell string the
|
|
983
|
+
human typed after `--rerun-cmd` (`pytest -q && npm test`), exactly the way `cleanroom
|
|
984
|
+
--run` and `golden-coverage --harness` take theirs -- the engine never decides what to
|
|
985
|
+
run, and neither does this TUI (ADR-008). The trust boundary is the human's own shell,
|
|
986
|
+
which ADR-037 states rather than pretends to mitigate: a misspelt flag runs whatever the
|
|
987
|
+
shell makes of it, in that directory, the same as typing it there.
|
|
988
|
+
|
|
989
|
+
Output is NOT captured: a test suite writes to the terminal the human is watching, and
|
|
990
|
+
swallowing it would replace measured output with a spinner. One function on purpose --
|
|
991
|
+
it is the boundary the suite replaces to assert the command and the ONE call per keypress
|
|
992
|
+
without running anything (AC-T-26). Returns the exit code."""
|
|
993
|
+
return subprocess.run(cmd, shell=True, cwd=cwd).returncode
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _snapshot_call(engine, ledger, repo):
|
|
997
|
+
"""The INGEST, made by the engine's own `snapshot` -- the same subcommand the dev loop
|
|
998
|
+
runs at every pass close. This is what makes `o` honest: the board moves on ingested
|
|
999
|
+
evidence or it does not move at all, and the TUI still builds no record of its own
|
|
1000
|
+
(ADR-033's rule, one more engine subcommand under it -- ADR-037).
|
|
1001
|
+
|
|
1002
|
+
Returns (returncode, the engine's own last line)."""
|
|
1003
|
+
argv = [sys.executable, engine, "snapshot", "--ledger", ledger, "--repo", repo]
|
|
1004
|
+
proc = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
1005
|
+
said = ((proc.stderr or b"").decode("utf-8", "replace").strip().splitlines()
|
|
1006
|
+
or (proc.stdout or b"").decode("utf-8", "replace").strip().splitlines())
|
|
1007
|
+
return proc.returncode, (said[-1] if said else "")
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def rerun_banner(cmd, repo):
|
|
1011
|
+
"""What the board says WHILE the command runs. Pure, so the frame that carries it stays
|
|
1012
|
+
a pure function of its inputs; the loop draws it before it blocks."""
|
|
1013
|
+
return ("rerun: %s %s running in %s (first configured repo) %s verdict keys locked"
|
|
1014
|
+
% (_safe(cmd), MID, _safe(repo), MID))
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def run_rerun(state, args, engine=None):
|
|
1018
|
+
"""One keypress -> the human's command, then the engine's ingest, then a reload upstream.
|
|
1019
|
+
Returns (ran?, the line the status bar shows).
|
|
1020
|
+
|
|
1021
|
+
The order is the decision: the snapshot runs **whether or not the command exited 0**. A
|
|
1022
|
+
red suite is evidence too, and the whole point of `o` is that what lands on the board is
|
|
1023
|
+
what a report says, never what an exit code narrated -- refusing to ingest a red run
|
|
1024
|
+
would leave the board showing the previous, greener measurement (measured red beats
|
|
1025
|
+
narrated green, kit 1.48.1). What a non-zero exit changes is the STATUS LINE, which names
|
|
1026
|
+
it, and nothing else.
|
|
1027
|
+
|
|
1028
|
+
Three refusals come first, none of which spawns anything: no `--rerun-cmd` (the command
|
|
1029
|
+
is the human's to supply, ADR-008), a `--state` frozen snapshot (the ledger on disk is
|
|
1030
|
+
not the one on screen -- the same refusal a verdict gets), and a ledger with no configured
|
|
1031
|
+
repo (there is no cwd to run in and no repo to ingest for)."""
|
|
1032
|
+
cmd = getattr(args, "rerun_cmd", None)
|
|
1033
|
+
if not cmd:
|
|
1034
|
+
return False, RERUN_MISSING_MSG
|
|
1035
|
+
if getattr(args, "state", None):
|
|
1036
|
+
return False, RERUN_FROZEN_MSG
|
|
1037
|
+
repo, path = first_repo(state)
|
|
1038
|
+
if not repo:
|
|
1039
|
+
return False, RERUN_NO_REPO_MSG
|
|
1040
|
+
eng = engine or engine_path()
|
|
1041
|
+
if not eng:
|
|
1042
|
+
return False, "qa_ledger.py not found next to uscha_top.py"
|
|
1043
|
+
cwd = os.path.join(os.path.dirname(os.path.realpath(args.ledger)) or ".", path or ".")
|
|
1044
|
+
if not os.path.isdir(cwd):
|
|
1045
|
+
return False, "repo path '%s' does not exist -- nothing was run" % path
|
|
1046
|
+
code = _rerun_call(cmd, cwd)
|
|
1047
|
+
rc, said = _snapshot_call(eng, args.ledger, repo)
|
|
1048
|
+
tail = said or ("snapshot exited %s" % rc)
|
|
1049
|
+
if rc != 0:
|
|
1050
|
+
return True, ("rerun exit %s %s snapshot FAILED (%s) -- nothing was ingested"
|
|
1051
|
+
% (code, MID, tail))
|
|
1052
|
+
if code != 0:
|
|
1053
|
+
# a red run is still a measurement: it is ingested, and the line says both facts.
|
|
1054
|
+
return True, "rerun exit %s (red) %s ingested: %s" % (code, MID, tail)
|
|
1055
|
+
return True, "rerun exit 0 %s ingested: %s" % (MID, tail)
|
|
1056
|
+
|
|
1057
|
+
|
|
798
1058
|
def after_verdict(sel, count):
|
|
799
1059
|
"""Where the cursor lands once the queue has been re-read: (selection, mode).
|
|
800
1060
|
|
|
@@ -808,7 +1068,26 @@ def after_verdict(sel, count):
|
|
|
808
1068
|
return max(0, min(sel, count - 1)), MODE_VERDICTS
|
|
809
1069
|
|
|
810
1070
|
|
|
811
|
-
def
|
|
1071
|
+
def is_rerun_key(key, mode, cooling=False, rerunning=False):
|
|
1072
|
+
"""Is this keypress a rerun request (ADR-037)? A pure predicate, and deliberately NOT a
|
|
1073
|
+
sixth member of `dispatch_mode`'s tuple: that shape is what M3 measured, and widening a
|
|
1074
|
+
measured contract so it can carry a second action is how a keymap grows a second write
|
|
1075
|
+
path nobody counted. The caller spends a True on exactly one `_rerun_call` + one
|
|
1076
|
+
`_snapshot_call`, never a loop (AC-T-29).
|
|
1077
|
+
|
|
1078
|
+
`o` answers on the BOARD only -- the verdicts queue and the drift pane have their own
|
|
1079
|
+
jobs -- and it is refused while a rerun is in flight or while the 250 ms cooldown after
|
|
1080
|
+
one is still running, so a HELD `o` is one rerun and not a queue of them (the same guard
|
|
1081
|
+
a held verdict key gets, ADR-033)."""
|
|
1082
|
+
return key in ("o", "O") and mode == MODE_BOARD and not cooling and not rerunning
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
# `rerunning=True` is never passed by `_loop`, and that is not an oversight: the rerun is
|
|
1086
|
+
# SYNCHRONOUS (the spawn blocks the loop, and `drain_keys` throws away whatever was typed
|
|
1087
|
+
# meanwhile), so the sync block plus the drain IS the lock -- the flag would have nothing to
|
|
1088
|
+
# guard against. It exists as a MEASURED contract: the predicate is what a future async rerun
|
|
1089
|
+
# would have to honour, and AC-T-27 pins it as a pure function rather than racing a terminal.
|
|
1090
|
+
def dispatch_mode(key, mode, sel, count, cooling=False, rerunning=False):
|
|
812
1091
|
"""The mode machine: key + current mode -> (mode, selection, quit?, reload?, verdict).
|
|
813
1092
|
|
|
814
1093
|
Pure, and the ONE place a keypress becomes a write decision -- `verdict` is a string the
|
|
@@ -821,9 +1100,21 @@ def dispatch_mode(key, mode, sel, count, cooling=False):
|
|
|
821
1100
|
stays pure). While it is true, `p`/`f`/`u` produce NO verdict: a key held down repeats,
|
|
822
1101
|
and the second repeat would judge the observation that just took the cursor's place. Every
|
|
823
1102
|
other key keeps working -- the cooldown blocks writes, not the reader."""
|
|
1103
|
+
if mode == MODE_DIFF:
|
|
1104
|
+
# a read-only pane with a read-only keymap: leave, re-read, or quit. No cursor (the
|
|
1105
|
+
# pane names what does not fit instead of scrolling) and no write of any kind.
|
|
1106
|
+
if key in ("q", "Q", "\x03"):
|
|
1107
|
+
return mode, sel, True, False, None
|
|
1108
|
+
if key in ("t", "T", "\x1b", "d", "D"):
|
|
1109
|
+
return MODE_BOARD, 0, False, False, None
|
|
1110
|
+
if key == "r":
|
|
1111
|
+
return mode, sel, False, True, None
|
|
1112
|
+
return mode, sel, False, False, None
|
|
824
1113
|
if mode != MODE_VERDICTS:
|
|
825
1114
|
if key in ("v", "V"):
|
|
826
1115
|
return MODE_VERDICTS, 0, False, False, None
|
|
1116
|
+
if key in ("d", "D"):
|
|
1117
|
+
return MODE_DIFF, 0, False, False, None
|
|
827
1118
|
sel, quit_now, reload_now = dispatch(key, sel, count)
|
|
828
1119
|
return MODE_BOARD, sel, quit_now, reload_now, None
|
|
829
1120
|
if key in ("q", "Q", "\x03"):
|
|
@@ -840,7 +1131,11 @@ def dispatch_mode(key, mode, sel, count, cooling=False):
|
|
|
840
1131
|
# an empty queue produces NO verdict: there is nothing selected to judge, and a
|
|
841
1132
|
# keypress that writes anyway would be a verdict the human never aimed at an OBS.
|
|
842
1133
|
# Neither does a queue still cooling from the last one.
|
|
843
|
-
|
|
1134
|
+
# `rerunning` is the same refusal for a different reason (ADR-037): while a rerun is
|
|
1135
|
+
# in flight the queue on screen was read BEFORE it, and a verdict recorded against a
|
|
1136
|
+
# queue the ingest is about to move is a judgement aimed at the wrong observation.
|
|
1137
|
+
return mode, sel, False, False, (VERDICTS[key] if (count and not cooling
|
|
1138
|
+
and not rerunning) else None)
|
|
844
1139
|
if len(str(key)) == 1 and key in "123456789":
|
|
845
1140
|
n = int(key) - 1
|
|
846
1141
|
return mode, (n if n < count else sel), False, False, None
|
|
@@ -904,6 +1199,25 @@ def _apply_and_advance(state, args, queue, cur, verdict):
|
|
|
904
1199
|
return state, cur, mode, status, True
|
|
905
1200
|
|
|
906
1201
|
|
|
1202
|
+
def _rerun_and_reload(state, args):
|
|
1203
|
+
"""ONE keypress -> ONE command -> ONE `snapshot` -> re-read. Returns (state, status).
|
|
1204
|
+
|
|
1205
|
+
A function of its own for the same structural reason `_apply_and_advance` is one, and the
|
|
1206
|
+
suite asserts it the same way (AC-T-29): the module's single call to `run_rerun` must have
|
|
1207
|
+
no `for`/`while` above it, so no later edit can quietly turn one keypress into a pass over
|
|
1208
|
+
the repos. The re-read afterwards is what makes the new measurement visible; DONE moves
|
|
1209
|
+
here or nowhere, because the ingest is the only thing that can move it (INV-TOP-03).
|
|
1210
|
+
|
|
1211
|
+
The input buffer is drained whether anything ran or not: a suite that takes a minute is
|
|
1212
|
+
exactly when a human types, and those keystrokes belong to the terminal they were typed
|
|
1213
|
+
into, not to the board that comes back."""
|
|
1214
|
+
ran, status = run_rerun(state, args)
|
|
1215
|
+
drain_keys()
|
|
1216
|
+
if not ran:
|
|
1217
|
+
return state, status
|
|
1218
|
+
return _reload(state, args), status
|
|
1219
|
+
|
|
1220
|
+
|
|
907
1221
|
def _loop(state, args):
|
|
908
1222
|
sel = 0 # the board's cursor
|
|
909
1223
|
vsel = 0 # the verdict queue's cursor, kept apart from it
|
|
@@ -947,6 +1261,26 @@ def _loop(state, args):
|
|
|
947
1261
|
cooldown_until = time.time() + VERDICT_COOLDOWN
|
|
948
1262
|
if wrote:
|
|
949
1263
|
_fresh, seen = _changed(paths, seen)
|
|
1264
|
+
elif is_rerun_key(key, mode, cooling=cooling):
|
|
1265
|
+
if getattr(args, "rerun_cmd", None):
|
|
1266
|
+
# the frame the human watches WHILE the command runs, drawn before
|
|
1267
|
+
# the spawn because the spawn blocks this loop until it returns.
|
|
1268
|
+
# That synchronous shape is also why the verdict lock is measured on
|
|
1269
|
+
# `dispatch_mode(..., rerunning=True)` and not raced against a
|
|
1270
|
+
# terminal (AC-T-27): while the suite runs, no key is read at all --
|
|
1271
|
+
# what is typed lands in the buffer and the drain throws it away.
|
|
1272
|
+
sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(render(
|
|
1273
|
+
state, terminal_size(args.cols, args.rows), sel=sel, plain=False,
|
|
1274
|
+
mode=mode, status=rerun_banner(args.rerun_cmd,
|
|
1275
|
+
first_repo(state)[0] or "?"))))
|
|
1276
|
+
sys.stdout.flush()
|
|
1277
|
+
state, status = _rerun_and_reload(state, args)
|
|
1278
|
+
# the same 250 ms a verdict gets, for the same reason: a held `o` must be
|
|
1279
|
+
# one rerun, not a queue of them.
|
|
1280
|
+
cooldown_until = time.time() + VERDICT_COOLDOWN
|
|
1281
|
+
_fresh, seen = _changed(paths, seen)
|
|
1282
|
+
elif key in ("o", "O") and cooling and mode == MODE_BOARD:
|
|
1283
|
+
status = RERUN_COOLDOWN_MSG
|
|
950
1284
|
elif key in VERDICTS and cooling:
|
|
951
1285
|
# the key WAS a verdict and it was refused: say why. A keypress that
|
|
952
1286
|
# vanishes silently reads as a dropped input, and the next reflex is to
|
|
@@ -997,6 +1331,13 @@ def build_parser():
|
|
|
997
1331
|
"set, `curate`'s own default applies). The person pressing the "
|
|
998
1332
|
"key is the author of the judgement -- the TUI never invents a "
|
|
999
1333
|
"name for it")
|
|
1334
|
+
parser.add_argument("--rerun-cmd", default=None,
|
|
1335
|
+
help="the shell command `o` reruns, in the first configured repo's "
|
|
1336
|
+
"directory (e.g. \"pytest -q\"). The tool NEVER guesses it and "
|
|
1337
|
+
"never reads it from config (ADR-008/037): without this flag "
|
|
1338
|
+
"`o` is inert and says so. After the command, the engine's own "
|
|
1339
|
+
"`snapshot` ingests the report -- on a red run too, because a "
|
|
1340
|
+
"red measurement is still a measurement")
|
|
1000
1341
|
parser.add_argument("--cols", type=int, default=None)
|
|
1001
1342
|
parser.add_argument("--rows", type=int, default=None)
|
|
1002
1343
|
return parser
|