@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 +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 +417 -0
- 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 +417 -0
- 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
|
|
@@ -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 · Spec-drift <span class="n" id="modes-n">—</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.
|
|
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,
|
|
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"
|