@andresmassello/uscha 1.90.0 → 1.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.90.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
43
+ **Kit v1.92.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.90.0, 52 subcommands, all measured):**
88
+ **What each arrow is, in the engine (kit 1.92.0, 53 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) |
@@ -145,7 +145,7 @@ and see which file, which test, and when.
145
145
  | `/uscha-mirador` | Bird's-eye HTML dashboard: readiness, trail, acceptance, loops |
146
146
  | `/uscha-status` | One-line progress readout, in chat |
147
147
 
148
- **A measurement engine** (`qa_ledger.py`, 52 subcommands, Python stdlib) that ingests
148
+ **A measurement engine** (`qa_ledger.py`, 53 subcommands, Python stdlib) that ingests
149
149
  evidence from **11 language stacks** — maven, gradle, ant, python, node, go, rust, dotnet,
150
150
  cpp, swift, flutter — and computes a readiness score with hard caps and visible provenance.
151
151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andresmassello/uscha",
3
- "version": "1.90.0",
3
+ "version": "1.92.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",
@@ -459,6 +459,18 @@ BLOCKER/CRITICAL + no open escalation), never self-declared — if it exits 1, t
459
459
  output lists exactly which facts are missing; do NOT open the PR, close the gap.
460
460
  A `spike/*` branch NEVER passes this gate (kit 1.19.0): spike code is disposable
461
461
  by contract — its only legitimate output is an ADR with lessons, never a merge.
462
+ - **Before declaring TERMINADO, run the seal (kit 1.92.0, INV-T1 / ADR-038):**
463
+
464
+ ```bash
465
+ python3 $QL check-terminado # 0 = sealed · 1 = broken · 2 = UNMEASURED
466
+ ```
467
+
468
+ It recomputes, from the ledger and the tree, whether the recorded evidence still belongs to
469
+ the code on disk: the repo subtree clean, `HEAD` equal to the last snapshot's commit, every
470
+ ingested report still hashing to what was recorded. **Exit 1** — do not declare TERMINADO:
471
+ re-snapshot on the CURRENT state (`snapshot --repo <REPO> --phase post`) and record why the
472
+ seal broke. **Exit 2** — the seal is UNMEASURED (no git, or no snapshot recorded): say so
473
+ plainly; an answer nobody could measure is not a TERMINADO either.
462
474
  - Ensure conventional-commit history is clean.
463
475
  - Open the PR(s). Confirm CI is green.
464
476
  - **STOP.** Present the PR link(s) and wait for the human to merge.
@@ -704,7 +704,14 @@ def _source_newest_mtime(repo_path):
704
704
  def _test_evidence_provenance(repo_path, repo_type):
705
705
  """Explain which JUnit reports back a snapshot and whether they are newer
706
706
  than relevant source/test files. No discoverable source is explicitly
707
- uncorrelated-but-usable to preserve synthetic/report-only workflows."""
707
+ uncorrelated-but-usable to preserve synthetic/report-only workflows.
708
+
709
+ Since 1.92.0 (ADR-038) each report also carries its CONTENT hash. Path and mtime
710
+ answer "which file, and was it written after the source"; they cannot answer "is
711
+ this still the file that was ingested" -- a log swapped or edited after the run
712
+ keeps its name and can keep its date. The hash is taken over the same files this
713
+ function already selects and the parser already reads: no new file is opened, and
714
+ `None` (unreadable) is recorded as absence, never as a match."""
708
715
  files = _junit_files_for(repo_path, repo_type)
709
716
  reports = []
710
717
  for path in files:
@@ -717,6 +724,7 @@ def _test_evidence_provenance(repo_path, repo_type):
717
724
  "mtime_ns": mtime_ns,
718
725
  "mtime": datetime.fromtimestamp(
719
726
  mtime_ns / 1_000_000_000, timezone.utc).isoformat(),
727
+ "sha256": _sha256_file(path),
720
728
  })
721
729
  if not reports:
722
730
  status = "not-applicable" if repo_type == "flutter" else "missing"
@@ -8567,6 +8575,277 @@ def _top_events(ledger, limit=TOP_EVENTS_TAIL):
8567
8575
  return events[:max(0, int(limit))]
8568
8576
 
8569
8577
 
8578
+ def _top_repos(ledger):
8579
+ """The configured repos, name and configured path, in configuration order (phase 2).
8580
+
8581
+ Two things the TUI must not decide for itself now have a source: WHICH repo a rerun runs
8582
+ in and ingests for (ADR-037 picks the first configured one, exactly as `_top_spec_pin`
8583
+ picks the sha it labels), and WHICH repo the `d` pane names when it tells the reader how
8584
+ to produce the spec-drift run that is missing. `path` is the path as CONFIGURED --
8585
+ relative to the ledger, never resolved here: an absolute machine path in the contract is
8586
+ a frozen state nobody else can render (the golden frames are files in a repo)."""
8587
+ out = []
8588
+ for r in ((ledger.get("config", {}) or {}).get("repos") or []):
8589
+ if isinstance(r, dict) and r.get("name"):
8590
+ out.append({"name": _top_clean(r["name"]), "path": _top_clean(r.get("path", "."))})
8591
+ return out
8592
+
8593
+
8594
+ def _top_spec_diff(ledger):
8595
+ """The advisory spec↔code drift the ledger ALREADY carries, projected for `d` (ADR-037).
8596
+
8597
+ It measures NOTHING: `spec-drift` (ADR-005) is the only command that walks git for this,
8598
+ and `top` is read-only by contract (ADR-032) -- so this reads `ledger["spec_drift"]`, the
8599
+ latest-state record that command leaves behind, and nothing else. **No recorded run ->
8600
+ `null`**, which the TUI renders as "no spec-drift run recorded", never as "no drift":
8601
+ "nobody measured" and "nothing is stale" are different statements and only one of them is
8602
+ ever free (INV-TOP-05).
8603
+
8604
+ Only `SPEC_STALE` rows travel: CLEAN/UNMAPPED/UNTRACKED/NO-CODE are the four ways a doc
8605
+ is NOT drifting, and `docs_total` keeps the denominator visible beside the count so the
8606
+ pane can say `3 of 21`. `code_ref` is ONE of the governed files that outran the doc --
8607
+ the record stores a capped, alphabetically sorted list and no per-file dates, so it is
8608
+ "a newer file", never "the newest one" (under-claim; `newer_files_total` carries the
8609
+ real cardinality)."""
8610
+ rec = ledger.get("spec_drift")
8611
+ if not isinstance(rec, dict):
8612
+ return None
8613
+ results = rec.get("results")
8614
+ results = results if isinstance(results, list) else []
8615
+ docs = [r for r in results if isinstance(r, dict)]
8616
+ stale = []
8617
+ for r in docs:
8618
+ if r.get("verdict") != "SPEC_STALE":
8619
+ continue
8620
+ # `newer_files` is a LIST in the record `spec-drift` writes, but the ledger is JSON on
8621
+ # disk: a hand edit can leave a string there, and a string is iterable -- the old
8622
+ # comprehension would have walked its characters and named `"n"` as the governed file
8623
+ # that outran the doc. A non-list is no evidence, so it yields no code_ref and 0.
8624
+ nf = r.get("newer_files")
8625
+ newer = [f for f in nf if isinstance(f, str)] if isinstance(nf, list) else []
8626
+ lag = r.get("lag_days_actual")
8627
+ total = r.get("newer_files_total")
8628
+ stale.append({
8629
+ "doc": _top_clean(r.get("file") or "?"),
8630
+ "lag_days": lag if isinstance(lag, (int, float)) else None,
8631
+ "code_ref": _top_clean(newer[0]) if newer else None,
8632
+ "newer_files_total": total if isinstance(total, int) else len(newer),
8633
+ "spec_committed_at": (_top_clean(r["spec_committed_at"])
8634
+ if r.get("spec_committed_at") else None),
8635
+ "newest_governed_at": (_top_clean(r["newest_governed_at"])
8636
+ if r.get("newest_governed_at") else None)})
8637
+ # worst lag first, the doc name as the tie-break: deterministic given the record, which
8638
+ # is what lets a golden frame be the oracle for this pane too (ADR-034).
8639
+ stale.sort(key=lambda s: (-(s["lag_days"] or 0), s["doc"]))
8640
+ lag_days = rec.get("max_lag_days")
8641
+ return {"measured_at": _top_clean(rec["at"]) if rec.get("at") else None,
8642
+ "repo": _top_clean(rec["repo"]) if rec.get("repo") else None,
8643
+ "max_lag_days": lag_days if isinstance(lag_days, int) else None,
8644
+ "docs_total": len(docs),
8645
+ "stale": stale,
8646
+ "advisory": True, # ADR-005: this never gates, here or anywhere
8647
+ "source": "spec-drift"}
8648
+
8649
+
8650
+ SEAL_NO_GIT = "no git work tree — seal UNMEASURED"
8651
+ SEAL_NO_COMMIT = "git repo without commits — seal UNMEASURED"
8652
+
8653
+
8654
+ def _seal_git(repo_path, *argv):
8655
+ """One git read for the seal, or None. Same OSError posture as `_evidence_origin`:
8656
+ git absent, or a repo path that does not exist, is an ordinary state of the world and
8657
+ must degrade to UNMEASURED, never take down the command it only annotates."""
8658
+ try:
8659
+ r = subprocess.run(["git"] + list(argv), cwd=repo_path, capture_output=True,
8660
+ text=True, encoding="utf-8", errors="replace")
8661
+ except OSError:
8662
+ return None
8663
+ return r if r.returncode == 0 else None
8664
+
8665
+
8666
+ def _seal_rel(work_tree, path):
8667
+ """`path` as git names it: relative to the work tree root, forward slashes, or None
8668
+ when it falls outside the tree.
8669
+
8670
+ BOTH sides go through `realpath` first. On Windows one API answers with an 8.3 short
8671
+ name (`RUNNER~1`) and another with the long one, and `relpath` between the two yields
8672
+ `..\\..` for a file plainly inside the tree — the CI-only failure paid for on
8673
+ 2026-08-02. A None here can only WITHHOLD an exemption, so the seal fails closed."""
8674
+ try:
8675
+ rel = os.path.relpath(os.path.realpath(path), os.path.realpath(work_tree))
8676
+ except (OSError, ValueError):
8677
+ return None
8678
+ rel = rel.replace("\\", "/")
8679
+ return None if rel == ".." or rel.startswith("../") else rel
8680
+
8681
+
8682
+ def _porcelain_paths(text):
8683
+ """Every path named by `git status --porcelain -uall`, rename destinations included.
8684
+
8685
+ A path with special characters comes back C-quoted; the quotes are stripped and any
8686
+ escape inside is left as-is. That can only fail to MATCH an exemption, which leaves the
8687
+ seal broken — the safe direction: an unrecognized change is dirt, never a pass."""
8688
+ out = []
8689
+ for line in (text or "").splitlines():
8690
+ if len(line) < 4:
8691
+ continue
8692
+ for part in line[3:].split(" -> "):
8693
+ part = part.strip()
8694
+ if len(part) >= 2 and part[0] == '"' and part[-1] == '"':
8695
+ part = part[1:-1]
8696
+ if part:
8697
+ out.append(part.replace("\\", "/"))
8698
+ return out
8699
+
8700
+
8701
+ def _sealed_state(ledger, ledger_path):
8702
+ """INV-T1 (ADR-038): is the recorded evidence bound to the code state on disk RIGHT NOW?
8703
+
8704
+ Derived at read time, never written: nothing here creates a file, and re-deriving it is
8705
+ the only way it can be trusted — a stored verdict is a claim about a tree that has moved
8706
+ on since. Three questions, all answerable from what the ledger already carries:
8707
+
8708
+ 1. is the TRACKED REPO'S SUBTREE clean -- `git status ... -- .` inside the configured
8709
+ repo path, the same per-path scoping `_evidence_origin` uses (ADR-007), so a
8710
+ monorepo sibling's edit is not this repo's dirt -- ignoring the ledger itself and
8711
+ the report files the last snapshot names (those two are the seal's own footprint,
8712
+ exactly as the reference `sh` package exempts `EVIDENCIA.md` and the logs it hashes);
8713
+ 2. is `HEAD` the commit that snapshot was taken at (`origin.commit`, ADR-007);
8714
+ 3. does every report the snapshot names still exist and still hash to what was
8715
+ recorded at ingest (`sha256`, added in 1.92.0).
8716
+
8717
+ Three verdicts, never two: `True` sealed, `False` a MEASURED break (the reasons say
8718
+ which), `None` UNMEASURED — no git work tree, or a snapshot old enough to predate the
8719
+ content hash. A measured break outranks an unmeasured check (fail-closed); an unmeasured
8720
+ check never reads as a pass (INV-TOP-05). The repo is the FIRST configured one, the same
8721
+ choice `_top_spec_pin` and `_top_repos` make, and it is named in the `repo` member so
8722
+ every reason below is read against it.
8723
+
8724
+ The block carries NO timestamp of its own. It is recomputed on every read, so a
8725
+ "checked at" would be a second wall clock inside a payload whose only other one is
8726
+ `generated_at` -- and two consecutive `top --json` runs must differ in nothing else
8727
+ (AC-T-24 measures exactly that, and caught this before it shipped)."""
8728
+ out = {"ok": None, "reasons": [], "commit": None, "repo": None}
8729
+ repos = (ledger.get("config", {}) or {}).get("repos") or []
8730
+ if not repos:
8731
+ out["reasons"].append("no repo configured — seal UNMEASURED")
8732
+ return out
8733
+ name = repos[0].get("name")
8734
+ path = repos[0].get("path", ".")
8735
+ out["repo"] = _top_clean(name) if name else None
8736
+
8737
+ top = _seal_git(path, "rev-parse", "--show-toplevel")
8738
+ if top is None or not top.stdout.strip():
8739
+ out["reasons"].append(SEAL_NO_GIT)
8740
+ return out
8741
+ head = _seal_git(path, "rev-parse", "HEAD")
8742
+ if head is None or not head.stdout.strip():
8743
+ # a git tree with no commit yet: `rev-parse HEAD` fails on an unborn branch. It is a
8744
+ # DIFFERENT absence from "not a work tree" and the reason says so -- the verdict is
8745
+ # the same UNMEASURED, but a reason that misnames the cause sends the reader to the
8746
+ # wrong fix.
8747
+ out["reasons"].append(SEAL_NO_COMMIT)
8748
+ return out
8749
+ head_sha = head.stdout.strip()
8750
+ work_tree = top.stdout.strip()
8751
+ out["commit"] = head_sha
8752
+
8753
+ snaps = ((ledger.get("repos") or {}).get(name) or {}).get("snapshots") or []
8754
+ if not snaps:
8755
+ # UNMEASURED, not broken. The reference `sh` package calls a missing EVIDENCIA.md a
8756
+ # rejection, and that is right for a file whose only job is to be the seal -- but a
8757
+ # snapshot is the INGEST record, and with none recorded there is nothing to compare
8758
+ # the tree against: not "the evidence is stale", not "the evidence was altered",
8759
+ # simply no anchor. Calling that a break would also make the seal non-deterministic
8760
+ # for a board whose evidence is read live from reports (the `top` fixtures are
8761
+ # exactly that), and INV-TOP-05 already fixes the posture: absence renders as
8762
+ # absence. The teeth stay where they bite -- `check-terminado` exits 2, so a hook or
8763
+ # a human gating on exit 0 still refuses. The LIMIT this leaves is stated out loud in
8764
+ # SPEC §4: a board at 100% with no snapshot at all carries no seal marker.
8765
+ out["reasons"].append("no snapshot recorded yet")
8766
+ return out
8767
+ snap = snaps[-1]
8768
+
8769
+ failures, unmeasured = [], []
8770
+ snap_commit = (snap.get("origin") or {}).get("commit")
8771
+ if not snap_commit:
8772
+ unmeasured.append("snapshot recorded no commit — seal UNMEASURED")
8773
+ elif snap_commit != head_sha:
8774
+ failures.append("stale seal: snapshot at %s, HEAD is %s"
8775
+ % (snap_commit[:8], head_sha[:8]))
8776
+
8777
+ reports = [r for r in ((snap.get("tests") or {}).get("reports") or [])
8778
+ if isinstance(r, dict) and r.get("path")]
8779
+ exempt = set()
8780
+ for candidate in [ledger_path] + [os.path.join(path, r["path"]) for r in reports]:
8781
+ rel = _seal_rel(work_tree, candidate)
8782
+ if rel:
8783
+ exempt.add(rel)
8784
+ # `core.quotepath=false`: without it git C-quotes any non-ASCII path, so a report named
8785
+ # `junit-acción.xml` would appear in the reason as `junit-acción.xml` -- a reason
8786
+ # nobody can act on, and an exemption that cannot match. Set on the command, never in the
8787
+ # user's config: the engine reads git, it does not configure it.
8788
+ st = _seal_git(path, "-c", "core.quotepath=false",
8789
+ "status", "--porcelain", "-uall", "--", ".")
8790
+ if st is None:
8791
+ unmeasured.append("repo subtree state unreadable — seal UNMEASURED")
8792
+ else:
8793
+ dirty = sorted(set(_porcelain_paths(st.stdout)) - exempt)
8794
+ if dirty:
8795
+ failures.append("repo subtree dirty: changes no snapshot covers (%s)" % dirty[0])
8796
+
8797
+ for r in reports:
8798
+ rel, full = r["path"], os.path.join(path, r["path"])
8799
+ if not os.path.isfile(full):
8800
+ failures.append("evidence missing: %s" % rel)
8801
+ elif not r.get("sha256"):
8802
+ unmeasured.append("evidence hash unmeasured: %s — no hash recorded at ingest "
8803
+ "(older snapshot, or the file was unreadable)" % rel)
8804
+ elif _sha256_file(full) != r["sha256"]:
8805
+ failures.append("evidence altered after ingest: %s" % rel)
8806
+
8807
+ out["reasons"] = failures + unmeasured
8808
+ out["ok"] = False if failures else (None if unmeasured else True)
8809
+ return out
8810
+
8811
+
8812
+ def cmd_check_terminado(args):
8813
+ """The enforcement side of INV-T1: the SAME `_sealed_state` derivation `top --json`
8814
+ publishes, with an exit code a hook or a human can act on. It measures the tree and
8815
+ prints; it writes nothing and it decides nothing else.
8816
+
8817
+ 0 = sealed · 1 = a measured break · 2 = UNMEASURED (no git work tree, no configured
8818
+ repo, a snapshot with no recorded hash -- or no readable ledger at all). 2 is the
8819
+ reference script's error class: "I could not answer" is not "yes".
8820
+
8821
+ A missing or corrupt ledger is UNMEASURED, not a break: `_load` exits 1 by design, and 1
8822
+ here means "I checked and the seal is broken". Reporting "not sealed" for a file the
8823
+ command never managed to read would be a verdict on evidence nobody looked at -- the
8824
+ exact failure this command exists to catch."""
8825
+ try:
8826
+ ledger = _load(args.ledger)
8827
+ except SystemExit as exc:
8828
+ message = exc.code if isinstance(exc.code, str) else None
8829
+ print(message or "[qa_ledger] check-terminado: ledger '%s' unreadable" % args.ledger)
8830
+ print("[qa_ledger] check-terminado: UNMEASURED — no readable ledger, no seal.")
8831
+ sys.exit(2)
8832
+ sealed = _sealed_state(ledger, args.ledger)
8833
+ ok = sealed.get("ok")
8834
+ if getattr(args, "json", False):
8835
+ print(json.dumps(sealed, indent=2, ensure_ascii=False))
8836
+ else:
8837
+ verdict = "SEALED" if ok is True else ("UNSEALED" if ok is False else "UNMEASURED")
8838
+ where = " at %s" % sealed["commit"][:8] if sealed.get("commit") else ""
8839
+ print("[qa_ledger] check-terminado: %s%s (repo %s)"
8840
+ % (verdict, where, sealed.get("repo") or "?"))
8841
+ for reason in sealed.get("reasons") or []:
8842
+ print(" - %s" % reason)
8843
+ if ok is not True:
8844
+ print(" TERMINADO is not enabled: re-run the evidence and `snapshot` "
8845
+ "on the current state.")
8846
+ sys.exit(0 if ok is True else (1 if ok is False else 2))
8847
+
8848
+
8570
8849
  def cmd_top(args):
8571
8850
  """`uscha top` — the WHOLE projection of the ledger as one read-only JSON (ADR-032).
8572
8851
 
@@ -8672,6 +8951,15 @@ def cmd_top(args):
8672
8951
  done, fail, quar = _n("MEASURED_PASS"), _n("MEASURED_FAIL"), _n("QUARANTINE")
8673
8952
  unmeasured = _n("UNMEASURED") + _n("TRACED")
8674
8953
  pct = _top_pct(done, total)
8954
+ # INV-TOP-06 (ADR-038): DONE never publishes 100% while the seal is MEASURED broken --
8955
+ # every criterion green against evidence that no longer belongs to this code state is
8956
+ # the same lie INV-TOP-01 forbids one row earlier. The cap lives HERE, beside the
8957
+ # rounding cap, so no renderer is the place it happens. An UNMEASURED seal (`ok is
8958
+ # None` -- no git) does NOT cap: absence of measurement is not evidence of a break, and
8959
+ # capping on it would put an unearned 99 on every non-git tree.
8960
+ sealed = _sealed_state(ledger, args.ledger)
8961
+ if sealed.get("ok") is False and pct >= 100:
8962
+ pct = 99
8675
8963
  measured = done + fail
8676
8964
  out = {
8677
8965
  "schema": TOP_SCHEMA,
@@ -8688,7 +8976,10 @@ def cmd_top(args):
8688
8976
  "events_tail": _top_events(ledger),
8689
8977
  "counts": {"measured_pass": done, "measured_fail": fail, "quarantine": quar,
8690
8978
  "unmeasured": _n("UNMEASURED"), "traced": 0, "tagged": 0, "total": total},
8691
- "terminado": {"done": done, "total": total, "pct": pct, "unmeasured": unmeasured},
8979
+ # `sealed` is DERIVED at read time from the ledger plus the tree (ADR-038); it is
8980
+ # never stored, so it cannot go stale the way the claim it guards can.
8981
+ "terminado": {"done": done, "total": total, "pct": pct, "unmeasured": unmeasured,
8982
+ "sealed": sealed},
8692
8983
  "debtors": {"machine": fail, "you": quar, "untagged": unmeasured},
8693
8984
  "honesty": {"measured": measured, "total": total,
8694
8985
  "pct": _top_pct(measured, total)},
@@ -8699,6 +8990,11 @@ def cmd_top(args):
8699
8990
  "medians": {"verdict_min": None, "loop_min": _top_loop_median_min(ledger)},
8700
8991
  "checks": _top_checks(ledger),
8701
8992
  "drift_pct": None, # spec_drift is per-file; an aggregate is ADR-035/3
8993
+ # phase 2 (ADR-037): the repos in configuration order -- the source for the ONE repo
8994
+ # `o` reruns in and `d` names -- and the advisory drift ALREADY recorded by
8995
+ # `spec-drift`. Both are reads of what the ledger holds; `top` still runs nothing.
8996
+ "repos": _top_repos(ledger),
8997
+ "spec_diff": _top_spec_diff(ledger),
8702
8998
  # the ONLY real series is the readiness SCORE history; an obligation-count burn-up
8703
8999
  # needs new persistence (ADR-035/2), so `kind` is emitted for the TUI to label it a
8704
9000
  # score trend and never as a count of closed obligations.
@@ -11875,6 +12171,14 @@ def build_parser():
11875
12171
  ptop.add_argument("--json", action="store_true")
11876
12172
  ptop.set_defaults(func=cmd_top)
11877
12173
 
12174
+ pct = sub.add_parser("check-terminado",
12175
+ help="INV-T1 (ADR-038): is TERMINADO sealed to the code state on "
12176
+ "disk? Same derivation `top --json` publishes. Exit 0 sealed, "
12177
+ "1 broken, 2 UNMEASURED")
12178
+ add_ledger(pct)
12179
+ pct.add_argument("--json", action="store_true")
12180
+ pct.set_defaults(func=cmd_check_terminado)
12181
+
11878
12182
  pb = sub.add_parser("rebuild",
11879
12183
  help="rebuild test: is the SPEC complete enough to "
11880
12184
  "regenerate the system? (completeness, not correctness)")