@andresmassello/uscha 1.65.0 → 1.67.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 +1 -1
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +13 -0
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +20 -3
- package/uscha-kit/.claude/skills/uscha-mirador/mirador.template.html +12 -2
- package/uscha-kit/.claude/skills/uscha-status/SKILL.md +9 -0
- package/uscha-kit/.claude-plugin/plugin.json +1 -1
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +1 -1
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/reports/junit/.curation-cases.json +1 -1
- package/uscha-kit/reports/junit/.oracle-cases.json +1 -1
- package/uscha-kit/skills/uscha-devloop/SKILL.md +13 -0
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +20 -3
- package/uscha-kit/skills/uscha-mirador/mirador.template.html +12 -2
- package/uscha-kit/skills/uscha-status/SKILL.md +9 -0
- package/uscha-kit/uscha.config.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Requires **Python 3.8+** on the machine (the engine is Python stdlib — no pip
|
|
|
40
40
|
runtime dependencies). The npm package is a thin router; the canonical installer is
|
|
41
41
|
`uscha-kit/install-uscha.py`.
|
|
42
42
|
|
|
43
|
-
**Kit v1.
|
|
43
|
+
**Kit v1.67.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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andresmassello/uscha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.67.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",
|
|
@@ -488,6 +488,19 @@ count, plateau flag per repo — to `ledger["measured"]` (what the statusline re
|
|
|
488
488
|
Without it the trail starves: the mirador shows "no history yet" and the statusline
|
|
489
489
|
falls back to counting checkboxes. Recording is append-only facts, never a gate.
|
|
490
490
|
|
|
491
|
+
Right after readiness, run the spec-maintenance advisory (kit 1.66.0):
|
|
492
|
+
|
|
493
|
+
```bash
|
|
494
|
+
python3 $QL spec-drift --repo <REPO>
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Milliseconds, deterministic, exit 0 always — it never gates, so running it every pass
|
|
498
|
+
adds zero ceremony. What it adds is VISIBILITY: the run lands in the ledger, so the
|
|
499
|
+
mirador card and `/uscha-status` show drift without anyone remembering the command —
|
|
500
|
+
the user this advisory exists for is precisely the one who never types it. If any doc
|
|
501
|
+
reads `SPEC_STALE`, mention it in the close block's `Blocks:` line as advisory context
|
|
502
|
+
(it blocks nothing; it informs the human's next conversation).
|
|
503
|
+
|
|
491
504
|
**Single-verdict view (kit 1.25.0, anti-ceremony).** By default `readiness` is ONE
|
|
492
505
|
screen: the verdict line, any conditional warning that actually fired (it speaks only
|
|
493
506
|
when it matters), and a `--- gates:` line that COLLAPSES every persisted gate record —
|
|
@@ -1868,6 +1868,15 @@ def _origin_label(origin):
|
|
|
1868
1868
|
return "%s/%s" % (sha, state)
|
|
1869
1869
|
|
|
1870
1870
|
|
|
1871
|
+
def _scope_path(ledger, name):
|
|
1872
|
+
"""Repo path for a scope, guarding the SYNTHETIC `integration` scope -- never present
|
|
1873
|
+
in config["repos"], where _repo_cfg exits on unknown names. This crash class shipped
|
|
1874
|
+
once (the clean-room gate, 1.63.0) and nearly shipped twice (spec-drift at pass close,
|
|
1875
|
+
caught by fresh review): two recurrences make it a helper, not a per-site pattern."""
|
|
1876
|
+
cfg = _repo_cfg(ledger, name) if name != "integration" else {"path": "."}
|
|
1877
|
+
return cfg.get("path", ".")
|
|
1878
|
+
|
|
1879
|
+
|
|
1871
1880
|
def _evidence_origin(repo_path):
|
|
1872
1881
|
"""WHERE the evidence came from: the commit it was measured at, and whether the tree
|
|
1873
1882
|
was clean (ADR-007). The engine's freshness check compares file MTIMES, so until now a
|
|
@@ -3145,7 +3154,7 @@ def cmd_spec_drift(args):
|
|
|
3145
3154
|
cfg = ledger["config"].get("defaults", {}).get("spec_drift") or {}
|
|
3146
3155
|
lag_days = int(args.max_lag_days if args.max_lag_days is not None
|
|
3147
3156
|
else cfg.get("max_lag_days", 30))
|
|
3148
|
-
repo_path =
|
|
3157
|
+
repo_path = _scope_path(ledger, args.repo)
|
|
3149
3158
|
|
|
3150
3159
|
# The spec surface is fixed by ADR-005: the repo SPEC.md plus every ADR.
|
|
3151
3160
|
spec_files = []
|
|
@@ -3756,7 +3765,7 @@ def cmd_roundtrip(args):
|
|
|
3756
3765
|
(ADR-011). Advisory end to end: exit 0 always, a report, never a gate."""
|
|
3757
3766
|
ledger = _load(args.ledger)
|
|
3758
3767
|
_repo_node(ledger, args.repo)
|
|
3759
|
-
repo_path =
|
|
3768
|
+
repo_path = _scope_path(ledger, args.repo)
|
|
3760
3769
|
st = _curation_state(repo_path)
|
|
3761
3770
|
if st is None:
|
|
3762
3771
|
print("ROUNDTRIP %s: no %s/ directory -- feature unused, nothing to trace."
|
|
@@ -3790,6 +3799,12 @@ def cmd_roundtrip(args):
|
|
|
3790
3799
|
out = {"repo": args.repo, "promoted": len(promoted), "covered": len(covered),
|
|
3791
3800
|
"missing": missing, "advisory": True,
|
|
3792
3801
|
"coverage_pct": round(100.0 * len(covered) / len(promoted), 1) if promoted else None}
|
|
3802
|
+
# Latest-state record so the mirador/status can surface it without anyone re-running the
|
|
3803
|
+
# command (the spec_drift pattern). A report that evaporates on exit is invisible to
|
|
3804
|
+
# every read surface -- which defeats the point of an advisory (found by auditing which
|
|
3805
|
+
# features actually REACH the user). Advisory data: no step counter, no gate record.
|
|
3806
|
+
ledger["roundtrip"] = dict(out, at=_now())
|
|
3807
|
+
_save(args.ledger, ledger)
|
|
3793
3808
|
if args.json:
|
|
3794
3809
|
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
3795
3810
|
else:
|
|
@@ -3812,7 +3827,7 @@ def cmd_curation_check(args):
|
|
|
3812
3827
|
candidate judged, or the feature unused."""
|
|
3813
3828
|
ledger = _load(args.ledger)
|
|
3814
3829
|
_repo_node(ledger, args.repo)
|
|
3815
|
-
repo_path =
|
|
3830
|
+
repo_path = _scope_path(ledger, args.repo)
|
|
3816
3831
|
st = _curation_state(repo_path)
|
|
3817
3832
|
if st is None:
|
|
3818
3833
|
if args.json:
|
|
@@ -4896,6 +4911,8 @@ def cmd_dashboard(args):
|
|
|
4896
4911
|
if ledger.get(CLEAN_ROOM_KEY):
|
|
4897
4912
|
out["clean_room"] = {r: [e for e in ledger[CLEAN_ROOM_KEY] if e.get("repo") == r][-1]
|
|
4898
4913
|
for r in {e.get("repo") for e in ledger[CLEAN_ROOM_KEY]}}
|
|
4914
|
+
if ledger.get("roundtrip"):
|
|
4915
|
+
out["roundtrip"] = ledger["roundtrip"]
|
|
4899
4916
|
if getattr(args, "json", False):
|
|
4900
4917
|
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
4901
4918
|
return
|
|
@@ -665,8 +665,8 @@ function renderModes(){
|
|
|
665
665
|
ledger fact visible. BOTH keys are conditional in dashboard --json, so a project
|
|
666
666
|
that never used either sees no card at all (absent block = identical view). */
|
|
667
667
|
const card=$("#card-modes");if(!card)return;
|
|
668
|
-
const fp=DATA.fast_path,sd=DATA.spec_drift;
|
|
669
|
-
if(!fp&&!sd){card.hidden=true;return;}
|
|
668
|
+
const fp=DATA.fast_path,sd=DATA.spec_drift,cr=DATA.clean_room;
|
|
669
|
+
if(!fp&&!sd&&!cr){card.hidden=true;return;}
|
|
670
670
|
const host=$("#modes");host.replaceChildren();
|
|
671
671
|
const parts=[];
|
|
672
672
|
if(fp){
|
|
@@ -677,6 +677,16 @@ function renderModes(){
|
|
|
677
677
|
node("span","chip "+(cls[e.verdict]||"st-todo"),String(e.verdict||"?"))));});
|
|
678
678
|
parts.push(Object.keys(fp).length+" repo"+(Object.keys(fp).length>1?"s":""));
|
|
679
679
|
}
|
|
680
|
+
if(cr){
|
|
681
|
+
const cls={GREEN:"st-done",RED:"st-block"};
|
|
682
|
+
Object.keys(cr).forEach(r=>{const e=cr[r]||{};
|
|
683
|
+
const sha=(e.ref||"").slice(0,8)||"?";
|
|
684
|
+
const wall=(typeof e.wall_ms==="number")?(" · "+(e.wall_ms/1000).toFixed(1)+"s"):"";
|
|
685
|
+
append(host,append(node("div","spec"),node("span","id","clean-room"),
|
|
686
|
+
node("span","t",r+" @ "+sha+wall),
|
|
687
|
+
node("span","chip "+(cls[e.status]||"st-prog"),String(e.status||"?"))));});
|
|
688
|
+
parts.push("clean-room ×"+Object.keys(cr).length);
|
|
689
|
+
}
|
|
680
690
|
if(sd){
|
|
681
691
|
const cls={SPEC_STALE:"st-block",CLEAN:"st-done",UNMAPPED:"st-todo",UNTRACKED:"st-todo"};
|
|
682
692
|
const res=sd.results||[];
|
|
@@ -100,6 +100,15 @@ nothing and gates nothing.
|
|
|
100
100
|
`spec-drift: N stale / M docs (advisory)` — or `spec-drift: no drift measured` when zero are
|
|
101
101
|
stale. Always label it advisory; it never explains a blocked phase. Absent key → no line.
|
|
102
102
|
|
|
103
|
+
**Clean-room (ADR-008):** if the ledger carries `clean_room` runs, add ONE line with the
|
|
104
|
+
latest run per repo: `clean-room: GREEN @ <sha8> (12.3s)` / `RED @ <sha8>`. When the gate is
|
|
105
|
+
declared (`mode: "final"`) and the latest run does not match the current HEAD, append
|
|
106
|
+
`(stale for gate)`. Absent key → no line.
|
|
107
|
+
|
|
108
|
+
**Roundtrip (ADR-009 slice 2):** if the ledger carries a `roundtrip` run, add ONE line:
|
|
109
|
+
`roundtrip: N/M promoted traceable by uscha-spec id (advisory)`. Absent key → no line —
|
|
110
|
+
silence is honest when the loop was never measured.
|
|
111
|
+
|
|
103
112
|
## Degradation (honest, specific)
|
|
104
113
|
|
|
105
114
|
- `measured` missing entirely → print: *"No measurement recorded yet — the trail
|
|
@@ -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.67.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, 35 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.67.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
|
|
4
4
|
|
|
5
5
|
Spec-driven orchestrator + multi-repo QA for Claude Code, with a deterministic ledger.
|
|
6
6
|
**Nine skills** (`uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`,
|
package/uscha-kit/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
uscha-kit 1.
|
|
1
|
+
uscha-kit 1.67.0
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"AC-RD-07": true, "AC-RD-01": true, "AC-RD-02": true, "AC-RD-03": true, "AC-RD-06": true, "AC-RD-04": true, "AC-RD-05": true}
|
|
1
|
+
{"AC-RD-07": true, "AC-RD-01": true, "AC-RD-02": true, "AC-RD-03": true, "AC-RD-06": true, "AC-RD-04": true, "AC-RD-05": true, "AC-RD-13": true}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"AC-RD-08": true, "AC-RD-09": true, "AC-RD-10": true, "AC-RD-11": true}
|
|
1
|
+
{"AC-RD-08": true, "AC-RD-09": true, "AC-RD-10": true, "AC-RD-11": true, "AC-RD-12": true}
|
|
@@ -488,6 +488,19 @@ count, plateau flag per repo — to `ledger["measured"]` (what the statusline re
|
|
|
488
488
|
Without it the trail starves: the mirador shows "no history yet" and the statusline
|
|
489
489
|
falls back to counting checkboxes. Recording is append-only facts, never a gate.
|
|
490
490
|
|
|
491
|
+
Right after readiness, run the spec-maintenance advisory (kit 1.66.0):
|
|
492
|
+
|
|
493
|
+
```bash
|
|
494
|
+
python3 $QL spec-drift --repo <REPO>
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Milliseconds, deterministic, exit 0 always — it never gates, so running it every pass
|
|
498
|
+
adds zero ceremony. What it adds is VISIBILITY: the run lands in the ledger, so the
|
|
499
|
+
mirador card and `/uscha-status` show drift without anyone remembering the command —
|
|
500
|
+
the user this advisory exists for is precisely the one who never types it. If any doc
|
|
501
|
+
reads `SPEC_STALE`, mention it in the close block's `Blocks:` line as advisory context
|
|
502
|
+
(it blocks nothing; it informs the human's next conversation).
|
|
503
|
+
|
|
491
504
|
**Single-verdict view (kit 1.25.0, anti-ceremony).** By default `readiness` is ONE
|
|
492
505
|
screen: the verdict line, any conditional warning that actually fired (it speaks only
|
|
493
506
|
when it matters), and a `--- gates:` line that COLLAPSES every persisted gate record —
|
|
@@ -1868,6 +1868,15 @@ def _origin_label(origin):
|
|
|
1868
1868
|
return "%s/%s" % (sha, state)
|
|
1869
1869
|
|
|
1870
1870
|
|
|
1871
|
+
def _scope_path(ledger, name):
|
|
1872
|
+
"""Repo path for a scope, guarding the SYNTHETIC `integration` scope -- never present
|
|
1873
|
+
in config["repos"], where _repo_cfg exits on unknown names. This crash class shipped
|
|
1874
|
+
once (the clean-room gate, 1.63.0) and nearly shipped twice (spec-drift at pass close,
|
|
1875
|
+
caught by fresh review): two recurrences make it a helper, not a per-site pattern."""
|
|
1876
|
+
cfg = _repo_cfg(ledger, name) if name != "integration" else {"path": "."}
|
|
1877
|
+
return cfg.get("path", ".")
|
|
1878
|
+
|
|
1879
|
+
|
|
1871
1880
|
def _evidence_origin(repo_path):
|
|
1872
1881
|
"""WHERE the evidence came from: the commit it was measured at, and whether the tree
|
|
1873
1882
|
was clean (ADR-007). The engine's freshness check compares file MTIMES, so until now a
|
|
@@ -3145,7 +3154,7 @@ def cmd_spec_drift(args):
|
|
|
3145
3154
|
cfg = ledger["config"].get("defaults", {}).get("spec_drift") or {}
|
|
3146
3155
|
lag_days = int(args.max_lag_days if args.max_lag_days is not None
|
|
3147
3156
|
else cfg.get("max_lag_days", 30))
|
|
3148
|
-
repo_path =
|
|
3157
|
+
repo_path = _scope_path(ledger, args.repo)
|
|
3149
3158
|
|
|
3150
3159
|
# The spec surface is fixed by ADR-005: the repo SPEC.md plus every ADR.
|
|
3151
3160
|
spec_files = []
|
|
@@ -3756,7 +3765,7 @@ def cmd_roundtrip(args):
|
|
|
3756
3765
|
(ADR-011). Advisory end to end: exit 0 always, a report, never a gate."""
|
|
3757
3766
|
ledger = _load(args.ledger)
|
|
3758
3767
|
_repo_node(ledger, args.repo)
|
|
3759
|
-
repo_path =
|
|
3768
|
+
repo_path = _scope_path(ledger, args.repo)
|
|
3760
3769
|
st = _curation_state(repo_path)
|
|
3761
3770
|
if st is None:
|
|
3762
3771
|
print("ROUNDTRIP %s: no %s/ directory -- feature unused, nothing to trace."
|
|
@@ -3790,6 +3799,12 @@ def cmd_roundtrip(args):
|
|
|
3790
3799
|
out = {"repo": args.repo, "promoted": len(promoted), "covered": len(covered),
|
|
3791
3800
|
"missing": missing, "advisory": True,
|
|
3792
3801
|
"coverage_pct": round(100.0 * len(covered) / len(promoted), 1) if promoted else None}
|
|
3802
|
+
# Latest-state record so the mirador/status can surface it without anyone re-running the
|
|
3803
|
+
# command (the spec_drift pattern). A report that evaporates on exit is invisible to
|
|
3804
|
+
# every read surface -- which defeats the point of an advisory (found by auditing which
|
|
3805
|
+
# features actually REACH the user). Advisory data: no step counter, no gate record.
|
|
3806
|
+
ledger["roundtrip"] = dict(out, at=_now())
|
|
3807
|
+
_save(args.ledger, ledger)
|
|
3793
3808
|
if args.json:
|
|
3794
3809
|
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
3795
3810
|
else:
|
|
@@ -3812,7 +3827,7 @@ def cmd_curation_check(args):
|
|
|
3812
3827
|
candidate judged, or the feature unused."""
|
|
3813
3828
|
ledger = _load(args.ledger)
|
|
3814
3829
|
_repo_node(ledger, args.repo)
|
|
3815
|
-
repo_path =
|
|
3830
|
+
repo_path = _scope_path(ledger, args.repo)
|
|
3816
3831
|
st = _curation_state(repo_path)
|
|
3817
3832
|
if st is None:
|
|
3818
3833
|
if args.json:
|
|
@@ -4896,6 +4911,8 @@ def cmd_dashboard(args):
|
|
|
4896
4911
|
if ledger.get(CLEAN_ROOM_KEY):
|
|
4897
4912
|
out["clean_room"] = {r: [e for e in ledger[CLEAN_ROOM_KEY] if e.get("repo") == r][-1]
|
|
4898
4913
|
for r in {e.get("repo") for e in ledger[CLEAN_ROOM_KEY]}}
|
|
4914
|
+
if ledger.get("roundtrip"):
|
|
4915
|
+
out["roundtrip"] = ledger["roundtrip"]
|
|
4899
4916
|
if getattr(args, "json", False):
|
|
4900
4917
|
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
4901
4918
|
return
|
|
@@ -665,8 +665,8 @@ function renderModes(){
|
|
|
665
665
|
ledger fact visible. BOTH keys are conditional in dashboard --json, so a project
|
|
666
666
|
that never used either sees no card at all (absent block = identical view). */
|
|
667
667
|
const card=$("#card-modes");if(!card)return;
|
|
668
|
-
const fp=DATA.fast_path,sd=DATA.spec_drift;
|
|
669
|
-
if(!fp&&!sd){card.hidden=true;return;}
|
|
668
|
+
const fp=DATA.fast_path,sd=DATA.spec_drift,cr=DATA.clean_room;
|
|
669
|
+
if(!fp&&!sd&&!cr){card.hidden=true;return;}
|
|
670
670
|
const host=$("#modes");host.replaceChildren();
|
|
671
671
|
const parts=[];
|
|
672
672
|
if(fp){
|
|
@@ -677,6 +677,16 @@ function renderModes(){
|
|
|
677
677
|
node("span","chip "+(cls[e.verdict]||"st-todo"),String(e.verdict||"?"))));});
|
|
678
678
|
parts.push(Object.keys(fp).length+" repo"+(Object.keys(fp).length>1?"s":""));
|
|
679
679
|
}
|
|
680
|
+
if(cr){
|
|
681
|
+
const cls={GREEN:"st-done",RED:"st-block"};
|
|
682
|
+
Object.keys(cr).forEach(r=>{const e=cr[r]||{};
|
|
683
|
+
const sha=(e.ref||"").slice(0,8)||"?";
|
|
684
|
+
const wall=(typeof e.wall_ms==="number")?(" · "+(e.wall_ms/1000).toFixed(1)+"s"):"";
|
|
685
|
+
append(host,append(node("div","spec"),node("span","id","clean-room"),
|
|
686
|
+
node("span","t",r+" @ "+sha+wall),
|
|
687
|
+
node("span","chip "+(cls[e.status]||"st-prog"),String(e.status||"?"))));});
|
|
688
|
+
parts.push("clean-room ×"+Object.keys(cr).length);
|
|
689
|
+
}
|
|
680
690
|
if(sd){
|
|
681
691
|
const cls={SPEC_STALE:"st-block",CLEAN:"st-done",UNMAPPED:"st-todo",UNTRACKED:"st-todo"};
|
|
682
692
|
const res=sd.results||[];
|
|
@@ -100,6 +100,15 @@ nothing and gates nothing.
|
|
|
100
100
|
`spec-drift: N stale / M docs (advisory)` — or `spec-drift: no drift measured` when zero are
|
|
101
101
|
stale. Always label it advisory; it never explains a blocked phase. Absent key → no line.
|
|
102
102
|
|
|
103
|
+
**Clean-room (ADR-008):** if the ledger carries `clean_room` runs, add ONE line with the
|
|
104
|
+
latest run per repo: `clean-room: GREEN @ <sha8> (12.3s)` / `RED @ <sha8>`. When the gate is
|
|
105
|
+
declared (`mode: "final"`) and the latest run does not match the current HEAD, append
|
|
106
|
+
`(stale for gate)`. Absent key → no line.
|
|
107
|
+
|
|
108
|
+
**Roundtrip (ADR-009 slice 2):** if the ledger carries a `roundtrip` run, add ONE line:
|
|
109
|
+
`roundtrip: N/M promoted traceable by uscha-spec id (advisory)`. Absent key → no line —
|
|
110
|
+
silence is honest when the loop was never measured.
|
|
111
|
+
|
|
103
112
|
## Degradation (honest, specific)
|
|
104
113
|
|
|
105
114
|
- `measured` missing entirely → print: *"No measurement recorded yet — the trail
|