@andresmassello/uscha 1.56.1 → 1.59.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
@@ -40,8 +40,8 @@ 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.56.1** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
44
- [changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG-1.56.1.md)
43
+ **Kit v1.59.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
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
 
47
47
  ---
@@ -76,7 +76,7 @@ and see which file, which test, and when.
76
76
  | `/uscha-mirador` | Bird's-eye HTML dashboard: readiness, trail, acceptance, loops |
77
77
  | `/uscha-status` | One-line progress readout, in chat |
78
78
 
79
- **A measurement engine** (`qa_ledger.py`, 29 subcommands, Python stdlib) that ingests
79
+ **A measurement engine** (`qa_ledger.py`, 31 subcommands, Python stdlib) that ingests
80
80
  evidence from **11 language stacks** — maven, gradle, ant, python, node, go, rust, dotnet,
81
81
  cpp, swift, flutter — and computes a readiness score with hard caps and visible provenance.
82
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andresmassello/uscha",
3
- "version": "1.56.1",
3
+ "version": "1.59.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",
@@ -18,6 +18,7 @@
18
18
  "!uscha-kit/**/*.pyc",
19
19
  "!uscha-kit/**/*.pyo",
20
20
  "!uscha-kit/CHANGELOG-*.md",
21
+ "!uscha-kit/CHANGELOG.md",
21
22
  "README.md",
22
23
  "LICENSE"
23
24
  ],
@@ -169,6 +169,27 @@ install `hooks/block-approved-writes.py` as a `PreToolUse` hook in
169
169
  `settings.json`, and add `*.approved.* binary` to `.gitattributes` (ships in
170
170
  `templates/.gitattributes`) so line endings can't lie in the byte-compare.
171
171
 
172
+ ## Phase 0a — Fast-path check (ADR-003; run FIRST, before planning ceremony)
173
+
174
+ If `defaults.fast_path` exists in config, run the measured classifier before demanding a
175
+ full spec package:
176
+
177
+ ```bash
178
+ python qa_ledger.py fastpath-eval --repo <name> --json # dry-run first
179
+ ```
180
+
181
+ - **ALLOW** and the operator wants the shortcut: re-run with
182
+ `--intent "<one sentence: what and why>"` to record it, then skip Phase 0's full-package
183
+ demand. The micro-contract replaces it: the recorded INTENT plus at least one new/modified
184
+ asserting test (readiness stays capped until that test shows up in measured evidence).
185
+ - **DENY**: state WHICH measured signal denied it — echo the engine's breakdown verbatim.
186
+ The skill wires; it never computes and never argues with the verdict. Proceed with the
187
+ normal full path. The operator may always choose the full path over an ALLOW; nothing —
188
+ operator, agent or flag — can force ALLOW over a DENY (INV-RIGOR-02).
189
+ - **Re-evaluate before the PR step** (same command, same intent): thresholds exceeded mid-run
190
+ flip the run to `ESCALATED` — the derived phase blocks pr-ready and readiness is capped.
191
+ Produce the ADR + ACCEPTANCE the change turned out to deserve, then `resolve-escalation`.
192
+
172
193
  ## Phase 0 — Plan (ADR-first)
173
194
 
174
195
  - **Read `CONSTITUTION.md` first (if present).** It lists the project invariants no SPEC
@@ -59,6 +59,7 @@ import math
59
59
  import os
60
60
  import re
61
61
  import shutil
62
+ import subprocess
62
63
  import sys
63
64
  import unicodedata
64
65
  import xml.etree.ElementTree as ET
@@ -2699,6 +2700,378 @@ def cmd_oscillation(args):
2699
2700
  sys.exit(1 if osc else 0)
2700
2701
 
2701
2702
 
2703
+ # --------------------------------------------------------------------------- #
2704
+ # fastpath-eval (ADR-003: fast-path entry by MEASURED signals, never opinion)
2705
+ # --------------------------------------------------------------------------- #
2706
+
2707
+ def _fp_glob_re(g):
2708
+ """Translate a protected-path glob to a regex. `**` crosses directories, `*`/`?` do not.
2709
+ Case-insensitive on purpose: Windows and macOS filesystems are."""
2710
+ g = g.replace("\\", "/")
2711
+ out, i = [], 0
2712
+ while i < len(g):
2713
+ c = g[i]
2714
+ if c == "*":
2715
+ if g[i:i + 2] == "**":
2716
+ i += 2
2717
+ if i < len(g) and g[i] == "/":
2718
+ # `**/` = any number of WHOLE directories (incl. zero) -- segment-anchored,
2719
+ # so `**/migrations/**` does not match `db_migrations/` by substring.
2720
+ out.append("(?:[^/]*/)*")
2721
+ i += 1
2722
+ else:
2723
+ out.append(".*")
2724
+ continue
2725
+ out.append("[^/]*")
2726
+ elif c == "?":
2727
+ out.append("[^/]")
2728
+ elif c in ".^$+{}[]|()":
2729
+ out.append("\\" + c)
2730
+ else:
2731
+ out.append(c)
2732
+ i += 1
2733
+ return re.compile("^(?:%s)$" % "".join(out), re.I)
2734
+
2735
+
2736
+ def cmd_fastpath_eval(args):
2737
+ """Measured verdict for the fast-path (ADR-003). ALLOW only when every signal passes;
2738
+ ANY ambiguity -- no config, no git, unresolvable base -- is DENY with the reason named
2739
+ (fail-closed: "could not measure" never grants the shortcut). With --intent the verdict is
2740
+ recorded in the ledger as a first-class entry; without it this is a dry-run. A prior ALLOW
2741
+ followed by a DENY re-eval escalates through the EXISTING escalation machinery, so the
2742
+ derived phase flips to `escalated` and pr-ready is blocked until the human resolves."""
2743
+ ledger = _load(args.ledger)
2744
+ _repo_node(ledger, args.repo)
2745
+ fp = ledger["config"].get("defaults", {}).get("fast_path")
2746
+ intent = (args.intent or "").strip()
2747
+ signals, deny = [], []
2748
+
2749
+ def sig(name, value, threshold, source, ok):
2750
+ signals.append({"name": name, "value": value, "threshold": threshold,
2751
+ "source": source, "at": _now(), "ok": bool(ok)})
2752
+ if not ok:
2753
+ deny.append(name)
2754
+
2755
+ if not isinstance(fp, dict) or fp.get("enabled") is False:
2756
+ sig("configured", False, "defaults.fast_path present and enabled",
2757
+ "config.defaults.fast_path", False)
2758
+ else:
2759
+ repo_path = _repo_cfg(ledger, args.repo).get("path", ".")
2760
+ base, base_src = args.base, "--base"
2761
+ if base:
2762
+ probe = subprocess.run(["git", "rev-parse", "--verify", base + "^{commit}"],
2763
+ cwd=repo_path, capture_output=True, text=True)
2764
+ if probe.returncode != 0:
2765
+ base = None
2766
+ base_src = "--base (unresolvable)"
2767
+ else:
2768
+ for cand in ("origin/main", "main"):
2769
+ r = subprocess.run(["git", "merge-base", "HEAD", cand], cwd=repo_path,
2770
+ capture_output=True, text=True)
2771
+ if r.returncode == 0 and r.stdout.strip():
2772
+ base, base_src = r.stdout.strip(), "merge-base HEAD %s" % cand
2773
+ break
2774
+ if not base:
2775
+ sig("base_ref", None, "a resolvable base commit",
2776
+ base_src if base_src != "--base" else "git merge-base HEAD origin/main|main",
2777
+ False)
2778
+ else:
2779
+ num = subprocess.run(["git", "diff", "--numstat", base], cwd=repo_path,
2780
+ capture_output=True, text=True, encoding="utf-8",
2781
+ errors="replace")
2782
+ if num.returncode != 0:
2783
+ sig("git_diff", None, "a readable diff",
2784
+ "git diff --numstat %s" % base[:12], False)
2785
+ else:
2786
+ files, loc, binaries = [], 0, []
2787
+ for line in num.stdout.splitlines():
2788
+ parts = line.split("\t")
2789
+ if len(parts) != 3:
2790
+ continue
2791
+ a, d, path = parts
2792
+ path = path.strip().replace("\\", "/")
2793
+ # RENAMES arrive as one descriptor -- `old => new` or `pre{old => new}post`.
2794
+ # Matching the raw descriptor against the globs let a rename INTO db/ walk
2795
+ # straight past protected_paths (found by fresh review). Expand it and
2796
+ # count BOTH sides: renaming a file out of a protected area is as
2797
+ # gate-worthy as renaming one in.
2798
+ if " => " in path:
2799
+ m_ = re.match(r"^(.*)\{(.*) => (.*)\}(.*)$", path)
2800
+ if m_:
2801
+ pre, old_, new_, post = m_.groups()
2802
+ files.append((pre + old_ + post).replace("//", "/"))
2803
+ files.append((pre + new_ + post).replace("//", "/"))
2804
+ else:
2805
+ old_, new_ = path.split(" => ", 1)
2806
+ files.append(old_)
2807
+ files.append(new_)
2808
+ else:
2809
+ files.append(path)
2810
+ if a == "-" or d == "-":
2811
+ # binary diff: LOC is UNMEASURABLE, and an unmeasurable signal must
2812
+ # deny, not silently count as zero (fail-closed).
2813
+ binaries.append(path)
2814
+ loc += (int(a) if a.isdigit() else 0) + (int(d) if d.isdigit() else 0)
2815
+ # UNTRACKED files are invisible to `git diff` -- and a small change is very
2816
+ # often a NEW file. Not counting them would under-measure exactly the case
2817
+ # this gate exists for, so they count as files + added lines (fail-closed).
2818
+ unt = subprocess.run(["git", "ls-files", "--others", "--exclude-standard"],
2819
+ cwd=repo_path, capture_output=True, text=True,
2820
+ encoding="utf-8", errors="replace")
2821
+ for path in unt.stdout.splitlines():
2822
+ path = path.strip()
2823
+ if not path:
2824
+ continue
2825
+ files.append(path.replace("\\", "/"))
2826
+ try:
2827
+ with open(os.path.join(repo_path, path), "rb") as fh:
2828
+ loc += fh.read().count(b"\n")
2829
+ except OSError:
2830
+ loc += 1 # an unreadable new file still counts as a change
2831
+ src = "git diff --numstat %s (+ untracked)" % base[:12]
2832
+ max_f = int(fp.get("max_files_changed", 3))
2833
+ max_l = int(fp.get("max_loc_delta", 80))
2834
+ sig("max_files_changed", len(files), max_f, src, len(files) <= max_f)
2835
+ sig("max_loc_delta", loc, max_l, src, loc <= max_l)
2836
+ if binaries:
2837
+ sig("binary_files", binaries[:5], "none (LOC unmeasurable on binary)",
2838
+ src, False)
2839
+ pats = fp.get("protected_paths",
2840
+ ["**/migrations/**", "**/*.appro" + "ved", "db/**"])
2841
+ hits = []
2842
+ for f in files:
2843
+ for g in pats:
2844
+ if _fp_glob_re(g).match(f):
2845
+ hits.append("%s (%s)" % (f, g))
2846
+ break
2847
+ sig("protected_paths", hits if hits else 0,
2848
+ "no touched file matches a protected glob", "config globs over " + src,
2849
+ not hits)
2850
+
2851
+ verdict = "ALLOW" if not deny else "DENY"
2852
+ # Escalation means "an ACTIVE fast-path run outgrew its thresholds" -- so it gates on the
2853
+ # LATEST entry for this repo being ALLOW, not on an ALLOW ever having existed. The first
2854
+ # version scanned all history, which misclassified every later unrelated DENY as ESCALATED
2855
+ # forever (found by fresh review, reproduced in ordinary sequential usage).
2856
+ _fp_prior = [e for e in ledger.get("fast_path", []) if e.get("repo") == args.repo]
2857
+ active_allow = bool(_fp_prior) and _fp_prior[-1].get("verdict") == "ALLOW"
2858
+ escalated = bool(intent and active_allow and verdict == "DENY"
2859
+ and "configured" not in deny)
2860
+ if escalated:
2861
+ verdict = "ESCALATED"
2862
+ out = {"repo": args.repo, "mode": "fast_path", "verdict": verdict,
2863
+ "intent": intent or None, "dry_run": not bool(intent), "signals": signals}
2864
+
2865
+ if intent:
2866
+ ledger.setdefault("fast_path", [])
2867
+ ledger["step_counter"] += 1
2868
+ entry = dict(out)
2869
+ entry.pop("dry_run", None)
2870
+ entry.update({"n": ledger["step_counter"], "at": _now()})
2871
+ ledger["fast_path"].append(entry)
2872
+ ledger["steps"].append({"n": ledger["step_counter"], "at": _now(),
2873
+ "kind": "fastpath-eval", "repo": args.repo})
2874
+ if escalated:
2875
+ # reuse the EXISTING escalation machinery: derived phase flips to `escalated`,
2876
+ # pr-ready is blocked and readiness capped until the human resolve-escalation --
2877
+ # after producing ADR + ACCEPTANCE, per ADR-003 (not a full discovery).
2878
+ ledger["step_counter"] += 1
2879
+ ledger["escalations"].append({
2880
+ "n": ledger["step_counter"], "at": _now(), "repo": args.repo,
2881
+ "reason": "fast-path thresholds exceeded mid-run: " + ", ".join(deny)
2882
+ + " -- produce ADR + ACCEPTANCE, then resolve-escalation"})
2883
+ ledger["steps"].append({"n": ledger["step_counter"], "at": _now(),
2884
+ "kind": "escalation", "repo": args.repo})
2885
+ _save(args.ledger, ledger)
2886
+
2887
+ if args.json:
2888
+ print(json.dumps(out, indent=2, ensure_ascii=False))
2889
+ else:
2890
+ print("FASTPATH %s: %s%s" % (args.repo, verdict,
2891
+ "" if intent else " (dry-run: no --intent, nothing recorded)"))
2892
+ for s_ in signals:
2893
+ print(" %s %s: %s / %s [%s]" % ("ok" if s_["ok"] else "!!",
2894
+ s_["name"], s_["value"], s_["threshold"], s_["source"]))
2895
+ if escalated:
2896
+ print(" -> ESCALATED: pr-ready is blocked; produce ADR + ACCEPTANCE, "
2897
+ "then resolve-escalation (a recorded human act)")
2898
+ sys.exit(0 if verdict == "ALLOW" else 1)
2899
+
2900
+
2901
+ # --------------------------------------------------------------------------- #
2902
+ # spec-drift (ADR-005: mechanical drift detection, advisory -- NEVER a gate)
2903
+ # --------------------------------------------------------------------------- #
2904
+
2905
+ def _sd_governs(path):
2906
+ """Parse the `governs:` glob list from a `---` frontmatter block at the top of a
2907
+ markdown file. Returns None when there is no frontmatter or no `governs:` key
2908
+ (UNMAPPED -- absence of a mapping is absence of measurement, not "no drift")."""
2909
+ try:
2910
+ with open(path, encoding="utf-8", errors="replace") as fh:
2911
+ lines = fh.read().splitlines()
2912
+ except OSError:
2913
+ return None
2914
+ if not lines or lines[0].strip() != "---":
2915
+ return None
2916
+ globs, in_governs = None, False
2917
+ # scan runs to the CLOSING fence, not an arbitrary window -- a governs: key late in a
2918
+ # long frontmatter block must not silently read as UNMAPPED (fresh-review finding).
2919
+ for ln in lines[1:]:
2920
+ s = ln.strip()
2921
+ if s == "---":
2922
+ break
2923
+ if s.startswith("governs:"):
2924
+ rest = s[len("governs:"):].strip()
2925
+ if rest.startswith("[") and rest.endswith("]"):
2926
+ globs = [x.strip().strip("\x27\x22")
2927
+ for x in rest[1:-1].split(",") if x.strip()]
2928
+ in_governs = False
2929
+ elif rest:
2930
+ # bare scalar (`governs: src/**`) -- a plausible authoring shorthand;
2931
+ # dropping it silently would report a misleading "globs match nothing".
2932
+ globs, in_governs = [rest.strip("\x27\x22")], False
2933
+ else:
2934
+ globs, in_governs = [], True
2935
+ continue
2936
+ if in_governs:
2937
+ if s.startswith("- "):
2938
+ globs.append(s[2:].strip().strip("\x27\x22"))
2939
+ elif s and not ln.startswith((" ", "\t")):
2940
+ in_governs = False
2941
+ return globs
2942
+
2943
+
2944
+ def _sd_iso(ct):
2945
+ import datetime as _dt
2946
+ return _dt.datetime.fromtimestamp(ct, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
2947
+
2948
+
2949
+ def _sd_last_commit_ct(repo_path, paths):
2950
+ """Newest commit epoch touching any of `paths` (chunked: command lines have limits).
2951
+ None when no commit touches them (untracked)."""
2952
+ newest = None
2953
+ for i in range(0, len(paths), 200):
2954
+ r = subprocess.run(["git", "log", "-1", "--format=%ct", "--"] + paths[i:i + 200],
2955
+ cwd=repo_path, capture_output=True, text=True,
2956
+ encoding="utf-8", errors="replace")
2957
+ out = r.stdout.strip().splitlines()
2958
+ if r.returncode == 0 and out and out[0].strip().isdigit():
2959
+ ct = int(out[0].strip())
2960
+ newest = ct if newest is None else max(newest, ct)
2961
+ return newest
2962
+
2963
+
2964
+ def cmd_spec_drift(args):
2965
+ """Advisory drift report (ADR-005): last commit date of each spec doc vs. the newest
2966
+ commit touching the files its `governs:` globs map to. SPEC_STALE when governed code
2967
+ outran the spec by more than max_lag_days; UNMAPPED when there is no (effective)
2968
+ mapping; UNTRACKED when the spec has no commit date to compare. This command NEVER
2969
+ gates: a stale spec is a prompt for a human conversation, not a blocked pipeline.
2970
+ Exit code 0 always."""
2971
+ ledger = _load(args.ledger)
2972
+ _repo_node(ledger, args.repo)
2973
+ cfg = ledger["config"].get("defaults", {}).get("spec_drift") or {}
2974
+ lag_days = int(args.max_lag_days if args.max_lag_days is not None
2975
+ else cfg.get("max_lag_days", 30))
2976
+ repo_path = _repo_cfg(ledger, args.repo).get("path", ".")
2977
+
2978
+ # The spec surface is fixed by ADR-005: the repo SPEC.md plus every ADR.
2979
+ spec_files = []
2980
+ if os.path.isfile(os.path.join(repo_path, "SPEC.md")):
2981
+ spec_files.append("SPEC.md")
2982
+ adr_dir = os.path.join(repo_path, "docs", "adr")
2983
+ if os.path.isdir(adr_dir):
2984
+ spec_files += sorted("docs/adr/" + f for f in os.listdir(adr_dir)
2985
+ if f.lower().endswith(".md"))
2986
+
2987
+ tracked = []
2988
+ ls = subprocess.run(["git", "ls-files"], cwd=repo_path, capture_output=True,
2989
+ text=True, encoding="utf-8", errors="replace")
2990
+ if ls.returncode == 0:
2991
+ tracked = [l.strip().replace("\\", "/") for l in ls.stdout.splitlines() if l.strip()]
2992
+
2993
+ results = []
2994
+ for spec in spec_files:
2995
+ governs = _sd_governs(os.path.join(repo_path, spec))
2996
+ row = {"file": spec, "governs": governs, "max_lag_days": lag_days}
2997
+ if governs is None:
2998
+ row.update({"verdict": "UNMAPPED", "reason": "no governs: frontmatter"})
2999
+ results.append(row)
3000
+ continue
3001
+ matched = []
3002
+ pats = [_fp_glob_re(g) for g in governs]
3003
+ for f in tracked:
3004
+ if f == spec:
3005
+ continue # a spec governing itself would always read fresh -- excluded
3006
+ if any(p.match(f) for p in pats):
3007
+ matched.append(f)
3008
+ if not matched:
3009
+ # A mapping that matches nothing measures nothing -- same absence, named.
3010
+ row.update({"verdict": "UNMAPPED", "reason": "globs match no tracked files"})
3011
+ results.append(row)
3012
+ continue
3013
+ spec_ct = _sd_last_commit_ct(repo_path, [spec])
3014
+ if spec_ct is None:
3015
+ row.update({"verdict": "UNTRACKED",
3016
+ "reason": "spec has no commit date to compare"})
3017
+ results.append(row)
3018
+ continue
3019
+ newest = _sd_last_commit_ct(repo_path, matched)
3020
+ lag_s = lag_days * 86400
3021
+ row.update({"governed_files": len(matched),
3022
+ "spec_committed_at": _sd_iso(spec_ct),
3023
+ "newest_governed_at": _sd_iso(newest) if newest is not None else None})
3024
+ if newest is not None and newest - spec_ct > lag_s:
3025
+ import datetime as _dt
3026
+ cutoff = _dt.datetime.fromtimestamp(
3027
+ spec_ct + lag_s, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+0000")
3028
+ newer = set()
3029
+ for i in range(0, len(matched), 200):
3030
+ r = subprocess.run(["git", "log", "--since", cutoff, "--name-only",
3031
+ "--format=", "--"] + matched[i:i + 200],
3032
+ cwd=repo_path, capture_output=True, text=True,
3033
+ encoding="utf-8", errors="replace")
3034
+ if r.returncode == 0:
3035
+ newer |= {l.strip().replace("\\", "/")
3036
+ for l in r.stdout.splitlines() if l.strip()}
3037
+ newer &= set(matched)
3038
+ row.update({"verdict": "SPEC_STALE",
3039
+ "lag_days_actual": round((newest - spec_ct) / 86400.0, 1),
3040
+ "newer_files": sorted(newer)[:20],
3041
+ "newer_files_total": len(newer)})
3042
+ else:
3043
+ row["verdict"] = "CLEAN"
3044
+ results.append(row)
3045
+
3046
+ out = {"repo": args.repo, "max_lag_days": lag_days, "results": results,
3047
+ "advisory": True}
3048
+
3049
+ # Latest-state record so the mirador can surface an advisory row. Advisory data,
3050
+ # not a step in the loop: no step_counter, no gate record, no readiness input.
3051
+ ledger["spec_drift"] = {"repo": args.repo, "at": _now(), "max_lag_days": lag_days,
3052
+ "results": results}
3053
+ _save(args.ledger, ledger)
3054
+
3055
+ if args.json:
3056
+ print(json.dumps(out, indent=2, ensure_ascii=False))
3057
+ else:
3058
+ print("SPEC-DRIFT %s (advisory, lag > %dd):" % (args.repo, lag_days))
3059
+ if not results:
3060
+ print(" no spec documents found (SPEC.md / docs/adr/*.md)")
3061
+ mark = {"SPEC_STALE": "!!", "CLEAN": "ok", "UNMAPPED": "--", "UNTRACKED": "--"}
3062
+ for r_ in results:
3063
+ line = " %s %s: %s" % (mark.get(r_["verdict"], "??"), r_["file"],
3064
+ r_["verdict"])
3065
+ if r_["verdict"] == "SPEC_STALE":
3066
+ line += " -- %d governed file(s) newer, e.g. %s" % (
3067
+ r_["newer_files_total"], ", ".join(r_["newer_files"][:3]))
3068
+ elif "reason" in r_:
3069
+ line += " (%s)" % r_["reason"]
3070
+ print(line)
3071
+ sys.exit(0)
3072
+
3073
+
3074
+
2702
3075
  def cmd_escalate(args):
2703
3076
  ledger = _load(args.ledger)
2704
3077
  _repo_node(ledger, args.repo)
@@ -3726,6 +4099,15 @@ def cmd_dashboard(args):
3726
4099
  "snapshots": snapshots,
3727
4100
  "evidence": ev, # receipts (kit 1.50.0): facts with paths + timestamps
3728
4101
  }
4102
+ # fast-path (ADR-003): latest verdict per repo, straight from the ledger. The key exists
4103
+ # ONLY when entries exist: an unconfigured/unused project keeps the exact prior schema
4104
+ # ("absent block = behavior identical" is a measured claim, and an unconditional key was
4105
+ # a schema change that contradicted it -- found by fresh review).
4106
+ if ledger.get("fast_path"):
4107
+ out["fast_path"] = {r: [e for e in ledger["fast_path"] if e.get("repo") == r][-1]
4108
+ for r in {e.get("repo") for e in ledger["fast_path"]}}
4109
+ if ledger.get("spec_drift"):
4110
+ out["spec_drift"] = ledger["spec_drift"]
3729
4111
  if getattr(args, "json", False):
3730
4112
  print(json.dumps(out, indent=2, ensure_ascii=False))
3731
4113
  return
@@ -3897,6 +4279,24 @@ def cmd_readiness(args):
3897
4279
  caps["blocker_critical"], "blocker_critical"))
3898
4280
  if any(not e.get("resolved_at") for e in ledger.get("escalations", [])):
3899
4281
  caps_active.append(("unresolved escalation", caps["escalation"], "escalation"))
4282
+ # AC-FP-06 (ADR-003): an active fast-path run still owes an asserting test. The latest
4283
+ # fast_path entry per repo being ALLOW, with NO measured test execution recorded at or
4284
+ # after it, caps readiness -- reusing the escalation cap value, per the existing cap
4285
+ # mechanics rather than inventing a new one.
4286
+ fp_cfg = ledger["config"].get("defaults", {}).get("fast_path")
4287
+ if isinstance(fp_cfg, dict) and fp_cfg.get("require_asserting_test", True):
4288
+ for _fp_repo in {e.get("repo") for e in ledger.get("fast_path", [])}:
4289
+ entries = [e for e in ledger["fast_path"] if e.get("repo") == _fp_repo]
4290
+ last = entries[-1] if entries else None
4291
+ if not last or last.get("verdict") != "ALLOW":
4292
+ continue
4293
+ node_fp = ledger["repos"].get(_fp_repo, {})
4294
+ tested = any((s.get("tests", {}).get("executed", 0) or 0) > 0
4295
+ and s.get("at", "") >= last.get("at", "")
4296
+ for s in node_fp.get("snapshots", []))
4297
+ if not tested:
4298
+ caps_active.append(("fast-path active in %s without a measured asserting test"
4299
+ % _fp_repo, caps["escalation"], "escalation"))
3900
4300
  if spec_doubts_open:
3901
4301
  caps_active.append((f"{len(spec_doubts_open)} spec-doubt open",
3902
4302
  caps["escalation"], "escalation"))
@@ -6403,6 +6803,23 @@ def build_parser():
6403
6803
  pe.add_argument("--reason", required=True)
6404
6804
  pe.set_defaults(func=cmd_escalate)
6405
6805
 
6806
+ pfp = sub.add_parser("fastpath-eval",
6807
+ help="measured fast-path verdict (ADR-003): ALLOW/DENY from the real diff; --intent records it")
6808
+ pfp.add_argument("--ledger", default="QA-LEDGER.json")
6809
+ pfp.add_argument("--repo", required=True)
6810
+ pfp.add_argument("--base", help="base commit/ref; default merge-base HEAD origin/main (fallback main)")
6811
+ pfp.add_argument("--intent", help="one sentence, what and why; without it the call is a dry-run")
6812
+ pfp.add_argument("--json", action="store_true")
6813
+ pfp.set_defaults(func=cmd_fastpath_eval)
6814
+
6815
+ psd = sub.add_parser("spec-drift",
6816
+ help="advisory spec-vs-code drift from git commit dates (ADR-005); never gates, exit 0 always")
6817
+ psd.add_argument("--ledger", default="QA-LEDGER.json")
6818
+ psd.add_argument("--repo", required=True)
6819
+ psd.add_argument("--max-lag-days", type=int, default=None,
6820
+ help="override defaults.spec_drift.max_lag_days (default 30)")
6821
+ psd.add_argument("--json", action="store_true")
6822
+ psd.set_defaults(func=cmd_spec_drift)
6406
6823
  pre = sub.add_parser("resolve-escalation",
6407
6824
  help="close open escalations for a repo (recorded event; "
6408
6825
  "lifts the readiness cap)")
@@ -67,6 +67,17 @@ than inventing a step. Keep the CONTENT in the conversation's language and the l
67
67
  `review_trigger`, `experiment_valid`, `experiment_missing`, and `expired`; top-level
68
68
  `adr_experiments` summarizes open/malformed/expired experiments. This is advisory
69
69
  visibility for measured hypotheses, not readiness scoring.
70
+ - **Fast-path (ADR-003):** `dashboard --json` carries `fast_path` — the latest verdict per
71
+ repo straight from the ledger, or null when none was requested. The template degrades when
72
+ absent, like every other field.
73
+ - **Spec-drift (ADR-005):** `dashboard --json` carries `spec_drift` — the latest advisory
74
+ run (per-document verdicts: SPEC_STALE / CLEAN / UNMAPPED / UNTRACKED) — only when a run
75
+ exists in the ledger; a virgin ledger keeps the exact prior schema. Advisory visibility of
76
+ the spec-maintenance tax, never readiness input.
77
+ - **Modes card:** the template draws one card for both modes — fast-path verdict chips per
78
+ repo (ALLOW green / ESCALATED amber / DENY red) and spec-drift rows per document, labeled
79
+ advisory. The card is hidden entirely when neither key exists (absent block = identical
80
+ view, same rule as the JSON).
70
81
  - **Session telemetry (optional, vendor-reported):** if `.uscha/telemetry.jsonl` exists,
71
82
  the skill aggregates it and MERGES a `telemetry` object into `DATA`. This is the ONE
72
83
  panel that is **narrated by the vendor (Claude Code), not measured by the engine** —
@@ -278,6 +278,11 @@ svg{display:block;width:100%;height:auto}
278
278
  The verdict above is all you need to look at. These roll up into it; they're here if you want to dig.
279
279
  </p>
280
280
  </div>
281
+
282
+ <div class="card col6" id="card-modes" hidden>
283
+ <h2>Fast-path &middot; Spec-drift <span class="n" id="modes-n">&mdash;</span></h2>
284
+ <div class="speclist" id="modes"></div>
285
+ </div>
281
286
  </section>
282
287
 
283
288
  <section class="telemetry" id="telemetry" hidden>
@@ -654,6 +659,39 @@ function renderTelemetry(){
654
659
  if(sec)sec.hidden=false;
655
660
  }
656
661
 
662
+ function renderModes(){
663
+ /* fast_path: latest measured verdict per repo (ADR-003). spec_drift: latest advisory
664
+ run (ADR-005) -- advisory means it never feeds readiness; this card only makes the
665
+ ledger fact visible. BOTH keys are conditional in dashboard --json, so a project
666
+ that never used either sees no card at all (absent block = identical view). */
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;}
670
+ const host=$("#modes");host.replaceChildren();
671
+ const parts=[];
672
+ if(fp){
673
+ const cls={ALLOW:"st-done",ESCALATED:"st-prog",DENY:"st-block"};
674
+ Object.keys(fp).forEach(r=>{const e=fp[r]||{};
675
+ append(host,append(node("div","spec"),node("span","id","fast-path"),
676
+ node("span","t",r+(e.intent?(" — "+e.intent):"")),
677
+ node("span","chip "+(cls[e.verdict]||"st-todo"),String(e.verdict||"?"))));});
678
+ parts.push(Object.keys(fp).length+" repo"+(Object.keys(fp).length>1?"s":""));
679
+ }
680
+ if(sd){
681
+ const cls={SPEC_STALE:"st-block",CLEAN:"st-done",UNMAPPED:"st-todo",UNTRACKED:"st-todo"};
682
+ const res=sd.results||[];
683
+ res.forEach(d=>{const extra=d.verdict==="SPEC_STALE"
684
+ ?(" — "+(d.newer_files_total||0)+" file(s) newer than the spec"):"";
685
+ append(host,append(node("div","spec"),node("span","id","spec-drift"),
686
+ node("span","t",String(d.file||"")+extra),
687
+ node("span","chip "+(cls[d.verdict]||"st-todo"),String(d.verdict||"?"))));});
688
+ const stale=res.filter(d=>d.verdict==="SPEC_STALE").length;
689
+ parts.push(stale?stale+" stale spec"+(stale>1?"s":""):"no drift measured");
690
+ host.appendChild(emptyHint("spec-drift is ADVISORY (ADR-005): it prompts a human look, it never gates."));
691
+ }
692
+ $("#modes-n").textContent=parts.join(" · ");
693
+ card.hidden=false;
694
+ }
657
695
  /* ==== status story: cómo viene / qué lo traba / qué sigue — all from measured DATA ==== */
658
696
  function renderStatus(){
659
697
  const sec=$("#status");if(!sec)return;
@@ -741,7 +779,7 @@ function applySnapshot(i){
741
779
  scrub.addEventListener("input",()=>applySnapshot(+scrub.value));
742
780
 
743
781
  /* ==== init ==== */
744
- renderStatus();renderAcceptance();renderAdrs();renderInv();renderHeat();renderLoops();renderSubs();renderExecutionPolicy();renderTelemetry();
782
+ renderStatus();renderModes();renderAcceptance();renderAdrs();renderInv();renderHeat();renderLoops();renderSubs();renderExecutionPolicy();renderTelemetry();
745
783
  $("#asof").textContent=snaps.length?snaps[snaps.length-1].date:"no history yet (readiness --record)";
746
784
  renderTrail(null);
747
785
  </script>
@@ -86,6 +86,14 @@ Line guide:
86
86
  WHEN the evidence was captured — facts do not move without new evidence. If the
87
87
  field is absent (nothing recorded yet), omit the line; never guess a timestamp.
88
88
 
89
+ **Fast-path mode (ADR-003):** if the ledger carries `fast_path` entries, add ONE line to the
90
+ block with the latest verdict per repo (`fast-path: ALLOW (intent...)` / `ESCALATED`). Absent
91
+ entries → no line at all: silence is honest when no mode was requested.
92
+
93
+ **Spec-drift (ADR-005):** if the ledger carries a `spec_drift` run, add ONE line:
94
+ `spec-drift: N stale / M docs (advisory)` — or `spec-drift: no drift measured` when zero are
95
+ stale. Always label it advisory; it never explains a blocked phase. Absent key → no line.
96
+
89
97
  ## Degradation (honest, specific)
90
98
 
91
99
  - `measured` missing entirely → print: *"No measurement recorded yet — the trail
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "uscha",
4
- "version": "1.56.1",
4
+ "version": "1.59.0",
5
5
  "displayName": "Uscha",
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, 29 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
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, 31 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
7
7
  "author": {
8
8
  "name": "Andres Massello",
9
9
  "url": "https://github.com/andresmassello"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uscha",
3
- "version": "1.56.1",
3
+ "version": "1.59.0",
4
4
  "description": "Uscha spec-driven development methodology for coding agents. Includes npm/npx router.",
5
5
  "author": {
6
6
  "name": "Andres Massello",