@andresmassello/uscha 1.89.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.
Files changed (36) hide show
  1. package/README.md +13 -9
  2. package/package.json +1 -1
  3. package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +221 -18
  4. package/uscha-kit/.claude/skills/uscha-devloop/uscha_top.py +438 -26
  5. package/uscha-kit/.claude-plugin/plugin.json +1 -1
  6. package/uscha-kit/.codex-plugin/plugin.json +1 -1
  7. package/uscha-kit/README.md +2 -6
  8. package/uscha-kit/VERSION +1 -1
  9. package/uscha-kit/install-uscha.py +5 -0
  10. package/uscha-kit/skills/uscha-devloop/qa_ledger.py +221 -18
  11. package/uscha-kit/skills/uscha-devloop/uscha_top.py +438 -26
  12. package/uscha-kit/uscha.config.json +1 -1
  13. package/uscha-kit/reports/junit/.bench-cases.json +0 -1
  14. package/uscha-kit/reports/junit/.bench-curate-cases.json +0 -1
  15. package/uscha-kit/reports/junit/.bootstrap-cases.json +0 -1
  16. package/uscha-kit/reports/junit/.cleanroom-cases.json +0 -1
  17. package/uscha-kit/reports/junit/.compile-cases.json +0 -1
  18. package/uscha-kit/reports/junit/.curation-cases.json +0 -1
  19. package/uscha-kit/reports/junit/.delta-cases.json +0 -1
  20. package/uscha-kit/reports/junit/.fa-cases.json +0 -1
  21. package/uscha-kit/reports/junit/.facts-cases.json +0 -1
  22. package/uscha-kit/reports/junit/.fastpath-cases.json +0 -1
  23. package/uscha-kit/reports/junit/.fidelity-cases.json +0 -1
  24. package/uscha-kit/reports/junit/.goldencov-cases.json +0 -1
  25. package/uscha-kit/reports/junit/.ir-cases.json +0 -1
  26. package/uscha-kit/reports/junit/.js-cases.json +0 -1
  27. package/uscha-kit/reports/junit/.lang-cases.json +0 -1
  28. package/uscha-kit/reports/junit/.lang3-cases.json +0 -1
  29. package/uscha-kit/reports/junit/.multi-cases.json +0 -1
  30. package/uscha-kit/reports/junit/.oracle-cases.json +0 -1
  31. package/uscha-kit/reports/junit/.origin-cases.json +0 -1
  32. package/uscha-kit/reports/junit/.r2-cases.json +0 -1
  33. package/uscha-kit/reports/junit/.rt-cases.json +0 -1
  34. package/uscha-kit/reports/junit/.sched-cases.json +0 -1
  35. package/uscha-kit/reports/junit/.specdrift-cases.json +0 -1
  36. package/uscha-kit/reports/junit/.top-cases.json +0 -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 tool executes · the method governs · evidence decides · the human approves.
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.89.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
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
 
@@ -82,25 +82,29 @@ automatic tool can perform: a human verdict.
82
82
  system (= 100% drift)
83
83
 
84
84
  round trip · bench-roundtrip — how much of the asset the reverse organs re-anchor
85
- from the compiled code: 0.062 measured (12 archetypes) — names, not yet semantics
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.89.0, 52 subcommands, all measured):**
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
- | Fidelity, honestly | `fidelity`, `roundtrip`, `bench-roundtrip`, `bench-r2` | per-compiler fidelity vector, id-level round trip, recoverability **0.062**, and the **noise floor** under every variance claim (ADR-014/022/027/030) |
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) |
98
98
 
99
99
  **Read the numbers the way the repo does.** 9 of 12 archetypes regenerate to the same system
100
- under an oracle the compilers never saw — that is the closed loop working. 0.062 is the mean
100
+ under an oracle the compilers never saw — that is the closed loop working. 0.828 is the mean
101
101
  *recoverability* of the asset from compiled code counting only static and behavioural
102
- footing, with the behaviour dimension still `UNMEASURED` it says the reverse organs anchor
103
- **names, not yet semantics**, and it is published rather than smoothed. And `bench-r2` measured
102
+ footing. It read **0.062** until 1.90.0, with the behaviour dimension `UNMEASURED`, because no
103
+ oracle case carried an AC tag a named absence, not a zero. The 12 bench oracles are now
104
+ curated per case (`ORACLE-TAGS-CURATED.json`, human-authored; payloads and expectations
105
+ untouched), so the dimension is measured and the number says the reverse organs anchor **names
106
+ and behaviour**. Both numbers are published rather than smoothed, and what moved between them
107
+ was the tagging, not the code under test. And `bench-r2` measured
104
108
  that same-model reruns differ structurally about as much as different models do (aggregate
105
109
  `NOISY`) — so one earlier variance narrative was **retracted**. Every claim above is a subcommand
106
110
  you can run; every unmeasured part is labeled. That honesty is the method applied to itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andresmassello/uscha",
3
- "version": "1.89.0",
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",
@@ -3423,6 +3423,15 @@ def cmd_golden_coverage(args):
3423
3423
 
3424
3424
  env = dict(os.environ)
3425
3425
  env["COVERAGE_PROCESS_START"] = rc
3426
+ # COVERAGE_FILE is set EXPLICITLY, not merely inherited. coverage.py resolves its
3427
+ # data_file with the environment variable AFTER the rc file, so an inherited
3428
+ # COVERAGE_FILE (the shape the kit's own suite has under USCHA_COVERAGE=1) silently
3429
+ # overrode the isolated data_file above and the capture landed in the caller's shared
3430
+ # file -- the map then read as "covers nothing" and AC-GM-08 went red under coverage
3431
+ # while passing plain. Writing it here pins the child to OUR file whether or not the
3432
+ # caller has one; the parent's own Coverage(data_file=...) already wins over the
3433
+ # environment because constructor arguments are applied last.
3434
+ env["COVERAGE_FILE"] = data_file
3426
3435
  env["PYTHONPATH"] = tmp + os.pathsep + env.get("PYTHONPATH", "")
3427
3436
  env["PYTHONIOENCODING"] = "utf-8"
3428
3437
  r = subprocess.run([sys.executable, harness], cwd=root, env=env,
@@ -5468,6 +5477,47 @@ def _static_surface_for(cd, unit):
5468
5477
  return so, []
5469
5478
 
5470
5479
 
5480
+ def _judged_env():
5481
+ """The environment a JUDGED program gets: this process's, minus the hooks that would make
5482
+ a third party instrument it, plus the one setting that keeps it from leaving anything
5483
+ behind in the tree it is judged from.
5484
+
5485
+ The withheld oracle measures a compiled implementation by its exit code and its stdout. A
5486
+ measurement that changes what it measures is a broken measurement, and coverage.py's
5487
+ documented subprocess hook does exactly that: with `COVERAGE_PROCESS_START` in the
5488
+ environment, coverage.py's own `.pth` (`a1_coverage.pth`, shipped in site-packages) starts
5489
+ a full Coverage in EVERY python process, at interpreter start-up, before any of that
5490
+ process's own code runs. The oracle's children are fixture programs that sit outside every
5491
+ `--source` root, so what they record is empty by construction -- pure cost, no measurement.
5492
+
5493
+ That cost is not theoretical (1.90.0). Running this repo's own smoke suite under
5494
+ `USCHA_COVERAGE=1` spawned ~14,000 such children; `coverage combine` reported
5495
+ "Combined 600 files, skipped 13702" -- 96% of them recorded nothing -- and late in the run
5496
+ Windows began refusing to create processes: seven bench archetypes flipped to FAIL because
5497
+ an oracle child could not start, and a py3.8 interpreter came back 3221225794
5498
+ (0xC0000142, STATUS_DLL_INIT_FAILED) with an empty stderr. Nothing was wrong with the
5499
+ engine or the fixtures; the instrument was crushing the machine it measured on.
5500
+
5501
+ Only the two variables that START coverage in a child are dropped. `COVERAGE_FILE` is
5502
+ left alone: it names a data file, it does not turn anything on, and a caller that set it
5503
+ means it.
5504
+
5505
+ `PYTHONDONTWRITEBYTECODE` is SET, and that one is not about coverage (1.90.0, ADR-030
5506
+ amended). A multi-unit compilation imports its sibling module, so running it writes
5507
+ `__pycache__/*.pyc` INTO the fixture directory -- and the round trip's own criterion is
5508
+ that the instrument regenerates nothing under the bench tree (AC-RT-01 snapshots the file
5509
+ list before and after). It only became reachable when the behaviour dimension started
5510
+ running cases: measured on a clean tree, `bench-roundtrip` left three `.pyc` files behind.
5511
+ In the suite the earlier `bench` block had already warmed the cache, so the red would have
5512
+ waited for a fresh CI clone to appear -- an ordering dependency, which is the kind of
5513
+ green worth nothing. Judging a program must not modify what is being judged."""
5514
+ env = dict(os.environ)
5515
+ for key in ("COVERAGE_PROCESS_START", "COVERAGE_PROCESS_CONFIG"):
5516
+ env.pop(key, None)
5517
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
5518
+ return env
5519
+
5520
+
5471
5521
  def _impl_interpreter(impl_path):
5472
5522
  """Resolve the interpreter argv prefix for one implementation file by extension (ADR-028):
5473
5523
  `.py` runs under this same Python (unchanged); `.js` runs under `node`, resolved from PATH.
@@ -5508,7 +5558,7 @@ def _run_oracle_case(impl_path, case):
5508
5558
  # sibling modules by bare name; single-unit impls are unaffected by their cwd
5509
5559
  r = subprocess.run(interp + [os.path.abspath(impl_path)], input=stdin,
5510
5560
  capture_output=True, text=True, encoding="utf-8", errors="replace",
5511
- timeout=_BOOTSTRAP_CASE_TIMEOUT,
5561
+ timeout=_BOOTSTRAP_CASE_TIMEOUT, env=_judged_env(),
5512
5562
  cwd=os.path.dirname(os.path.abspath(impl_path)) or None)
5513
5563
  got, out, err = r.returncode, r.stdout, None
5514
5564
  except subprocess.TimeoutExpired:
@@ -6307,6 +6357,41 @@ def _rt_ids_in(text):
6307
6357
  return out
6308
6358
 
6309
6359
 
6360
+ def _rt_ac_key(cid):
6361
+ """ONE spelling for comparing an AC id, so the round-trip's footings cannot disagree over
6362
+ punctuation (ADR-030, amended 1.90.0).
6363
+
6364
+ The three footings read ids from three places that spell them differently: an IR node id is
6365
+ written the way a human wrote the acceptance line and keeps its padding (`AC-DD-07`), a
6366
+ source comment or a curated oracle tag may write `AC-DD-7` or `AC_DD_07`, and `_rt_ids_in`
6367
+ only upper-cases what it finds. Comparing those raw was a match that depended on a zero.
6368
+ The normal form is ADR-036's OWN grammar (`_ac_tag_ids`, family + integer, padding and
6369
+ separator dropped) -- reused rather than reimplemented, because a second grammar for the
6370
+ same id is the drift this function exists to prevent. It is applied to BOTH sides of the
6371
+ behaviour comparison and nowhere else: `per_node["id"]`, the static footing and the
6372
+ manifest footing keep the IR's own spelling, so no report changes shape. A non-AC id
6373
+ (INV-*, ADR-*) has no behaviour footing at all and falls through upper-cased."""
6374
+ ids = _ac_tag_ids(str(cid or ""))
6375
+ return ids[0] if ids else str(cid or "").upper().replace("_", "-")
6376
+
6377
+
6378
+ def _rt_case_tags(case):
6379
+ """Every AC id ONE withheld-oracle case is tagged with, normalised (ADR-030, amended
6380
+ 1.90.0): the ids literally referenced in its `name`, plus the ids in its curated `ac` list.
6381
+
6382
+ `ac` is a human-curated field on the case (provenance:
6383
+ `tests/fixtures/diamond-bench/ORACLE-TAGS-CURATED.json`) -- the payload and the expectations
6384
+ are untouched by it, so tagging cannot change what a case MEASURES, only what the id map
6385
+ says it measures. Absence stays absence: a missing key, a null, a non-list, or a list with
6386
+ nothing usable in it yields no tags, and an entry whose cases carry none is still
6387
+ behaviour-UNMEASURED rather than behaviour-zero."""
6388
+ out = set(_rt_ids_in(case.get("name", "")))
6389
+ raw = case.get("ac")
6390
+ if isinstance(raw, (list, tuple)):
6391
+ out |= {t for t in (str(x).strip() for x in raw if isinstance(x, str)) if t}
6392
+ return {_rt_ac_key(t) for t in out}
6393
+
6394
+
6310
6395
  def _rt_read_source(cd, unit):
6311
6396
  try:
6312
6397
  with open(os.path.join(cd, unit.replace("/", os.sep)), encoding="utf-8",
@@ -6321,8 +6406,9 @@ def _rt_compilation(entry_dir, cd, ir_graph, cases):
6321
6406
  IR node, whether the mechanical reverse organs find footing for it in the artifact --
6322
6407
  (a) static: a source unit's text or a static observation literally references the id;
6323
6408
  (b) manifest: the compiler's validated trace manifest maps the node to a unit that exists;
6324
- (c) behaviour: for AC nodes, at least one withheld-oracle case tagged with the id passes
6325
- (UNMEASURED when no case carries any AC tag). It regenerates NOTHING: no IR', no spec --
6409
+ (c) behaviour: for AC nodes, at least one withheld-oracle case tagged with the id passes --
6410
+ tagged in the case NAME or in its curated `ac` list (ADR-030 amended 1.90.0), and
6411
+ UNMEASURED when no case carries any tag at all. It regenerates NOTHING: no IR', no spec --
6326
6412
  a coverage over the human-authored IR, plus the list of nodes nothing anchors."""
6327
6413
  cj = os.path.join(cd, "COMPILATION.json")
6328
6414
  try:
@@ -6347,26 +6433,40 @@ def _rt_compilation(entry_dir, cd, ir_graph, cases):
6347
6433
  if e.get("unit") in units:
6348
6434
  for nid in e.get("implements") or []:
6349
6435
  manifest_ids.add(str(nid).upper())
6350
- # (c) behaviour footing: oracle cases whose NAME carries an AC id (payload tags are not a
6351
- # convention the bench oracles use today -- absence named, not faked)
6436
+ # (c) behaviour footing: the oracle cases an id is tagged with -- in the case NAME, and
6437
+ # since 1.90.0 in the case's curated `ac` list as well (ADR-030 amended). Before the
6438
+ # curation there was no tag anywhere in this fixture and the whole dimension read
6439
+ # UNMEASURED: a named absence, which is what it was, and it is now measured instead.
6352
6440
  tagged = {}
6353
- for case in cases:
6354
- for cid in _rt_ids_in(case.get("name", "")):
6355
- tagged.setdefault(cid, []).append(case)
6356
- behaviour_measured = bool(tagged)
6441
+ for i, case in enumerate(cases):
6442
+ for cid in _rt_case_tags(case):
6443
+ tagged.setdefault(cid, []).append(i)
6444
+ # MEASURED only when at least one tag names an AC node of THIS entry's IR (1.90.0 review):
6445
+ # a typo, a stray ADR/INV id or a tag from another archetype anchors nothing, and a tag
6446
+ # that anchors nothing must leave the dimension a named absence, never a measured zero.
6447
+ ir_ac_keys = {_rt_ac_key(n) for n in node_ids if str(n).upper().startswith("AC")}
6448
+ behaviour_measured = any(_rt_ac_key(k) in ir_ac_keys for k in tagged)
6357
6449
  behaviour_ids = set()
6358
6450
  entry_unit = _entry_unit(c.get("source") or [])
6359
6451
  impl = os.path.join(cd, entry_unit.replace("/", os.sep)) if entry_unit else None
6360
6452
  if behaviour_measured and impl and os.path.isfile(impl):
6361
- for cid, cs in tagged.items():
6362
- if any(_run_oracle_case(impl, case).get("ok") for case in cs):
6453
+ # one RUN per case, not one per tag: a case tagged with three ids used to be executed
6454
+ # three times, and running a judged program more often than the oracle asked is both
6455
+ # slower and a different experiment
6456
+ verdicts = {}
6457
+ for cid, idxs in tagged.items():
6458
+ for i in idxs:
6459
+ if i not in verdicts:
6460
+ verdicts[i] = bool(_run_oracle_case(impl, cases[i]).get("ok"))
6461
+ if any(verdicts[i] for i in idxs):
6363
6462
  behaviour_ids.add(cid)
6364
6463
  per_node = []
6365
6464
  anchored = 0
6366
6465
  for nid in node_ids:
6367
6466
  a_s = nid in static_ids
6368
6467
  a_m = nid in manifest_ids
6369
- a_b = (nid in behaviour_ids) if (behaviour_measured and nid.startswith("AC-")) else None
6468
+ a_b = ((_rt_ac_key(nid) in behaviour_ids)
6469
+ if (behaviour_measured and nid.startswith("AC-")) else None)
6370
6470
  anchored_any = a_s or a_m or bool(a_b)
6371
6471
  # the MEASURED footing excludes the manifest: the manifest is what the compiler
6372
6472
  # CLAIMED (and the prompt handed it the ids), so counting it as recovered would be
@@ -6423,6 +6523,26 @@ def _rt_entry(entry_dir, name):
6423
6523
  return rec
6424
6524
 
6425
6525
 
6526
+ def _rt_behaviour_note(agg):
6527
+ """The report's closing sentence about the behaviour dimension, DERIVED from the aggregate
6528
+ instead of asserted (1.90.0).
6529
+
6530
+ It used to end 'which today is every entry' -- true when written, false the moment the
6531
+ diamond-bench oracles were curated with per-case `ac` tags, and nothing would have caught
6532
+ it: a generated document is exactly where a hardcoded claim rots unseen. Three honest
6533
+ states, one of them chosen by the number beside it."""
6534
+ measured, total = agg.get("behaviour_measured") or 0, agg.get("measured") or 0
6535
+ if not total or not measured:
6536
+ return ("which today is every entry: the honest state of reverse discovery is that it "
6537
+ "anchors names, not semantics, until oracles carry per-AC tags")
6538
+ if measured == total:
6539
+ return ("which today is no entry: every oracle here carries per-AC tags, so this "
6540
+ "dimension is measured rather than named absent, and what it attributes is "
6541
+ "behaviour and not merely a name")
6542
+ return ("which today is %d of the %d measured entries -- the rest carry per-AC tags and "
6543
+ "are attributed by behaviour" % (total - measured, total))
6544
+
6545
+
6426
6546
  def _render_rt_md(recs, agg):
6427
6547
  lines = ["<!-- GENERATED by qa_ledger.py bench-roundtrip (ADR-030) -- measured run; do not hand-edit. -->",
6428
6548
  "", "# DIAMOND-ROUNDTRIP -- how much of the pinned IR the reverse organs can anchor in each compiled artifact", "",
@@ -6460,10 +6580,8 @@ def _render_rt_md(recs, agg):
6460
6580
  "", "*The manifest dimension is what the compiler CLAIMED (validated for shape, "
6461
6581
  "not truth) and is excluded from recoverability; the static dimension is what the "
6462
6582
  "artifact literally names; the behaviour dimension is what the withheld oracle can "
6463
- "attribute per AC -- and it is UNMEASURED wherever oracle cases are not tagged with "
6464
- "AC ids, which today is every entry: the honest state of reverse discovery is that "
6465
- "it anchors names, not semantics, until oracles carry per-AC tags. None of the three "
6466
- "is a spec.*", ""]
6583
+ "attribute per AC -- UNMEASURED wherever the entry's oracle cases carry no AC tag, "
6584
+ "%s. None of the three is a spec.*" % _rt_behaviour_note(agg), ""]
6467
6585
  return "\n".join(lines)
6468
6586
 
6469
6587
 
@@ -8288,9 +8406,17 @@ def _top_clean(text):
8288
8406
  observation statement, a tool name) -- human and CLI input -- so an ESC or a C0 byte
8289
8407
  inside one would be a control sequence the board obeys instead of prints. It dies HERE,
8290
8408
  in the engine, and the renderer drops it again on the way out: two cheap guards over one
8291
- attack surface. What is filtered is exactly C0 and DEL (ADR-032)."""
8409
+ attack surface.
8410
+
8411
+ Filtered (1.90.0): C0 and DEL, the C1 range U+0080-U+009F (a terminal decoding the text
8412
+ as latin-1 reads those as CSI/OSC introducers, so they are the same attack in one byte
8413
+ less), and every Unicode format character (category `Cf`) -- U+200B and friends occupy no
8414
+ column but do occupy a codepoint, and U+202E reverses the rest of the line. A character
8415
+ that cannot be seen must not be able to move what is."""
8292
8416
  txt = "".join(" " if c in ("\t", "\n", "\r") else c for c in str(text))
8293
- txt = "".join(c for c in txt if ord(c) >= 32 and ord(c) != 127)
8417
+ txt = "".join(c for c in txt
8418
+ if ord(c) >= 32 and ord(c) != 127 and not 0x80 <= ord(c) <= 0x9F
8419
+ and unicodedata.category(c) != "Cf")
8294
8420
  return " ".join(txt.split())
8295
8421
 
8296
8422
 
@@ -8441,6 +8567,78 @@ def _top_events(ledger, limit=TOP_EVENTS_TAIL):
8441
8567
  return events[:max(0, int(limit))]
8442
8568
 
8443
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
+
8444
8642
  def cmd_top(args):
8445
8643
  """`uscha top` — the WHOLE projection of the ledger as one read-only JSON (ADR-032).
8446
8644
 
@@ -8573,6 +8771,11 @@ def cmd_top(args):
8573
8771
  "medians": {"verdict_min": None, "loop_min": _top_loop_median_min(ledger)},
8574
8772
  "checks": _top_checks(ledger),
8575
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),
8576
8779
  # the ONLY real series is the readiness SCORE history; an obligation-count burn-up
8577
8780
  # needs new persistence (ADR-035/2), so `kind` is emitted for the TUI to label it a
8578
8781
  # score trend and never as a count of closed obligations.