@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 +3 -3
- package/package.json +2 -1
- package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +21 -0
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +454 -7
- package/uscha-kit/.claude/skills/uscha-mirador/SKILL.md +11 -0
- package/uscha-kit/.claude/skills/uscha-mirador/mirador.template.html +39 -1
- package/uscha-kit/.claude/skills/uscha-status/SKILL.md +8 -0
- package/uscha-kit/.claude-plugin/plugin.json +2 -2
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +54 -1
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/reports/junit/.fastpath-cases.json +1 -0
- package/uscha-kit/reports/junit/.specdrift-cases.json +1 -0
- package/uscha-kit/skills/uscha-devloop/SKILL.md +21 -0
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +454 -7
- package/uscha-kit/skills/uscha-mirador/SKILL.md +11 -0
- package/uscha-kit/skills/uscha-mirador/mirador.template.html +39 -1
- package/uscha-kit/skills/uscha-status/SKILL.md +8 -0
- package/uscha-kit/uscha.config.json +15 -1
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.
|
|
44
|
-
[changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG
|
|
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`,
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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** —
|