@andresmassello/uscha 1.56.0 → 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.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
44
- [changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG-1.56.0.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.0",
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
@@ -96,6 +97,36 @@ SOURCE_EXT = {
96
97
  # --------------------------------------------------------------------------- #
97
98
  # ledger io
98
99
  # --------------------------------------------------------------------------- #
100
+ # Reports come from the user's build, not from us, and the engine is stdlib-only by contract --
101
+ # `defusedxml` is not available. A byte ceiling is the honest mitigation for the realistic
102
+ # failure (a runaway or hostile report exhausting memory on the operator's own machine). It is
103
+ # NOT protection against a determined attacker: entity expansion inside the ceiling still
104
+ # expands. SECURITY.md says so rather than implying the parser is hardened.
105
+ MAX_REPORT_BYTES = 64 * 1024 * 1024 # 64 MB: orders of magnitude above any real JUnit run
106
+
107
+
108
+ class ReportTooLarge(Exception):
109
+ pass
110
+
111
+
112
+ def _parse_xml(source):
113
+ """ET.parse with a size ceiling. Accepts a path or an open binary/text file object."""
114
+ if hasattr(source, "read"):
115
+ head = source.read(MAX_REPORT_BYTES + 1)
116
+ if len(head) > MAX_REPORT_BYTES:
117
+ raise ReportTooLarge("report exceeds %d bytes" % MAX_REPORT_BYTES)
118
+ if isinstance(head, bytes):
119
+ return ET.ElementTree(ET.fromstring(head))
120
+ return ET.ElementTree(ET.fromstring(head))
121
+ try:
122
+ size = os.path.getsize(str(source))
123
+ except OSError:
124
+ size = 0
125
+ if size > MAX_REPORT_BYTES:
126
+ raise ReportTooLarge("%s exceeds %d bytes" % (source, MAX_REPORT_BYTES))
127
+ return ET.parse(str(source))
128
+
129
+
99
130
  def _now():
100
131
  return datetime.now(timezone.utc).isoformat(timespec="seconds")
101
132
 
@@ -168,7 +199,7 @@ def _repo_cfg(ledger, name):
168
199
  def _jacoco_line_counter(xml_path):
169
200
  """Return (missed, covered) for the report-level LINE counter."""
170
201
  try:
171
- root = ET.parse(xml_path).getroot()
202
+ root = _parse_xml(xml_path).getroot()
172
203
  except ET.ParseError:
173
204
  return 0, 0
174
205
  for c in root.findall("counter"):
@@ -237,7 +268,7 @@ def cobertura_coverage(repo_path):
237
268
  if not path:
238
269
  return {"covered": 0, "missed": 0, "pct": 0.0, "report_found": False}
239
270
  try:
240
- root = ET.parse(path).getroot()
271
+ root = _parse_xml(path).getroot()
241
272
  except (ET.ParseError, OSError):
242
273
  return {"covered": 0, "missed": 0, "pct": 0.0, "report_found": False}
243
274
  lc, lv = root.get("lines-covered"), root.get("lines-valid")
@@ -358,7 +389,7 @@ def _invalid_junit(path, detail):
358
389
 
359
390
  def _parse_junit_xml(path):
360
391
  try:
361
- root = ET.parse(path).getroot()
392
+ root = _parse_xml(path).getroot()
362
393
  except (ET.ParseError, OSError) as exc:
363
394
  _invalid_junit(path, exc)
364
395
  root_kind = _local(root.tag)
@@ -464,7 +495,7 @@ def _perclass_xml_count(patterns, skip_root=None, tolerant=False):
464
495
  # simply not be ours. Skip it instead of aborting the whole run -- but
465
496
  # NEVER silently: every drop is returned so the ledger can surface it.
466
497
  try:
467
- root = ET.parse(f).getroot()
498
+ root = _parse_xml(f).getroot()
468
499
  except (ET.ParseError, OSError) as exc:
469
500
  dropped.append({"path": f, "reason": f"unreadable XML: {exc}"})
470
501
  continue
@@ -751,7 +782,7 @@ def _ac_tags(repo_path, repo_type):
751
782
  except OSError:
752
783
  pass
753
784
  try:
754
- root = ET.parse(f).getroot()
785
+ root = _parse_xml(f).getroot()
755
786
  except (ET.ParseError, OSError):
756
787
  continue
757
788
  for tc in root.iter():
@@ -1046,7 +1077,7 @@ def _invalid_static_report(path, label, detail):
1046
1077
 
1047
1078
  def _parse_static_xml(path, label, root_name):
1048
1079
  try:
1049
- root = ET.parse(path).getroot()
1080
+ root = _parse_xml(path).getroot()
1050
1081
  except (ET.ParseError, OSError) as exc:
1051
1082
  _invalid_static_report(path, label, exc)
1052
1083
  if _local(root.tag) != root_name:
@@ -2669,6 +2700,378 @@ def cmd_oscillation(args):
2669
2700
  sys.exit(1 if osc else 0)
2670
2701
 
2671
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
+
2672
3075
  def cmd_escalate(args):
2673
3076
  ledger = _load(args.ledger)
2674
3077
  _repo_node(ledger, args.repo)
@@ -3696,6 +4099,15 @@ def cmd_dashboard(args):
3696
4099
  "snapshots": snapshots,
3697
4100
  "evidence": ev, # receipts (kit 1.50.0): facts with paths + timestamps
3698
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"]
3699
4111
  if getattr(args, "json", False):
3700
4112
  print(json.dumps(out, indent=2, ensure_ascii=False))
3701
4113
  return
@@ -3867,6 +4279,24 @@ def cmd_readiness(args):
3867
4279
  caps["blocker_critical"], "blocker_critical"))
3868
4280
  if any(not e.get("resolved_at") for e in ledger.get("escalations", [])):
3869
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"))
3870
4300
  if spec_doubts_open:
3871
4301
  caps_active.append((f"{len(spec_doubts_open)} spec-doubt open",
3872
4302
  caps["escalation"], "escalation"))
@@ -4699,7 +5129,7 @@ def _find_pit_report(path_arg):
4699
5129
 
4700
5130
 
4701
5131
  def _pit_metrics(xml_path):
4702
- root = ET.parse(xml_path).getroot()
5132
+ root = _parse_xml(xml_path).getroot()
4703
5133
  total = killed = survived = no_cov = excluded = 0
4704
5134
  by_file = {}
4705
5135
  for mut in root.iter("mutation"):
@@ -6373,6 +6803,23 @@ def build_parser():
6373
6803
  pe.add_argument("--reason", required=True)
6374
6804
  pe.set_defaults(func=cmd_escalate)
6375
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)
6376
6823
  pre = sub.add_parser("resolve-escalation",
6377
6824
  help="close open escalations for a repo (recorded event; "
6378
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** —