@andresmassello/uscha 1.63.0 → 1.64.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 +2 -2
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +288 -0
- package/uscha-kit/.claude/skills/uscha-reverse-discovery/SKILL.md +65 -24
- package/uscha-kit/.claude-plugin/plugin.json +2 -2
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +20 -1
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/reports/junit/.curation-cases.json +1 -0
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +288 -0
- package/uscha-kit/skills/uscha-reverse-discovery/SKILL.md +65 -24
- package/uscha-kit/templates/BEHAVIOR-LEDGER.md +13 -0
- package/uscha-kit/uscha.config.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Requires **Python 3.8+** on the machine (the engine is Python stdlib — no pip
|
|
|
40
40
|
runtime dependencies). The npm package is a thin router; the canonical installer is
|
|
41
41
|
`uscha-kit/install-uscha.py`.
|
|
42
42
|
|
|
43
|
-
**Kit v1.
|
|
43
|
+
**Kit v1.64.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
|
|
44
44
|
[changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG.md)
|
|
45
45
|
(the per-release changelogs live in the repo, not in the npm tarball)
|
|
46
46
|
|
|
@@ -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`, 34 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.64.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",
|
|
@@ -2634,6 +2634,29 @@ def _derive_phase(ledger, name, node, k, qa_order):
|
|
|
2634
2634
|
# requires clean-room evidence that is GREEN and pinned to the CURRENT HEAD. A new commit
|
|
2635
2635
|
# makes the previous run stale for the gate, the same staleness posture the rest of the
|
|
2636
2636
|
# engine takes: evidence certifies the thing it was measured against, nothing later.
|
|
2637
|
+
# curation gate (ADR-009, INV-CURATION-01). In use the moment discovery/ exists --
|
|
2638
|
+
# creating candidates IS the opt-in. Fail-closed both ways: an unjudged candidate blocks,
|
|
2639
|
+
# and a malformation blocks too, because "could not validate" must never read as judged.
|
|
2640
|
+
_cu_cfg = (_repo_cfg(ledger, name) if name != "integration"
|
|
2641
|
+
else {"path": "."}) # synthetic scope: never in config["repos"]
|
|
2642
|
+
_cu = _curation_state(_cu_cfg.get("path", "."))
|
|
2643
|
+
if _cu is not None:
|
|
2644
|
+
if _cu["malformed"] or _cu["ledger_errors"] or _cu["append_only"] == "violation":
|
|
2645
|
+
_bits = [m["candidate"] for m in _cu["malformed"][:3]]
|
|
2646
|
+
if _cu["ledger_errors"]:
|
|
2647
|
+
_bits.append(BEHAVIOR_LEDGER_FILE + " malformado")
|
|
2648
|
+
if _cu["append_only"] == "violation":
|
|
2649
|
+
_bits.append(BEHAVIOR_LEDGER_FILE + " editado (append-only)")
|
|
2650
|
+
reasons.append("curation invalida: " + "; ".join(_bits)
|
|
2651
|
+
+ " -- corregir antes de avanzar")
|
|
2652
|
+
conv = False
|
|
2653
|
+
elif _cu["unjudged"]:
|
|
2654
|
+
reasons.append("candidata(s) sin veredicto humano: "
|
|
2655
|
+
+ ", ".join(_cu["unjudged"][:3])
|
|
2656
|
+
+ (" (+%d)" % (len(_cu["unjudged"]) - 3)
|
|
2657
|
+
if len(_cu["unjudged"]) > 3 else "")
|
|
2658
|
+
+ " -- INV-CURATION-01: sin juicio no hay promocion")
|
|
2659
|
+
conv = False
|
|
2637
2660
|
_cr = _cr_cfg(ledger)
|
|
2638
2661
|
if _cr and _cr.get("mode") == "final":
|
|
2639
2662
|
_head = None
|
|
@@ -3510,6 +3533,264 @@ def cmd_cleanroom(args):
|
|
|
3510
3533
|
|
|
3511
3534
|
|
|
3512
3535
|
|
|
3536
|
+
# --------------------------------------------------------------------------- #
|
|
3537
|
+
# curation (ADR-009/010: candidates in quarantine, verdicts in the behavior
|
|
3538
|
+
# ledger, and a promotion gate the ENGINE measures -- INV-CURATION-01)
|
|
3539
|
+
# --------------------------------------------------------------------------- #
|
|
3540
|
+
|
|
3541
|
+
BEHAVIOR_LEDGER_FILE = "BEHAVIOR-LEDGER.md"
|
|
3542
|
+
CANDIDATE_DIR = "discovery"
|
|
3543
|
+
_BL_VERDICTS = ("preserve", "fix", "undefined")
|
|
3544
|
+
|
|
3545
|
+
|
|
3546
|
+
def _parse_candidate(path):
|
|
3547
|
+
"""Parse one candidate's frontmatter. Returns (data, errors); a candidate with errors
|
|
3548
|
+
is INVALID and named -- never silently skipped, because a skipped candidate would walk
|
|
3549
|
+
past the promotion gate unjudged."""
|
|
3550
|
+
errors = []
|
|
3551
|
+
try:
|
|
3552
|
+
# utf-8-sig: a BOM-adding editor must not turn a well-formed candidate into a
|
|
3553
|
+
# false "no frontmatter" (fresh-review LOW)
|
|
3554
|
+
with open(path, encoding="utf-8-sig", errors="replace") as fh:
|
|
3555
|
+
lines = fh.read().splitlines()
|
|
3556
|
+
except OSError as exc:
|
|
3557
|
+
return None, ["unreadable: %s" % exc]
|
|
3558
|
+
if not lines or lines[0].strip() != "---":
|
|
3559
|
+
return None, ["no frontmatter (evidence/confidence are mandatory, ADR-009)"]
|
|
3560
|
+
fm, i = [], 1
|
|
3561
|
+
while i < len(lines) and lines[i].strip() != "---":
|
|
3562
|
+
fm.append(lines[i]); i += 1
|
|
3563
|
+
if i >= len(lines):
|
|
3564
|
+
return None, ["frontmatter never closes"]
|
|
3565
|
+
etype, refs, conf, in_refs, in_evidence = None, [], None, False, False
|
|
3566
|
+
for ln in fm:
|
|
3567
|
+
s = ln.strip()
|
|
3568
|
+
indented = ln.startswith((" ", "\t"))
|
|
3569
|
+
if s.startswith("evidence:") and not indented:
|
|
3570
|
+
in_evidence = True; in_refs = False
|
|
3571
|
+
elif s.startswith("type:"):
|
|
3572
|
+
# scope + duplicates are MALFORMATION, not last-value-wins: a stray top-level
|
|
3573
|
+
# type:/confidence: after the evidence block silently overrode the nested one
|
|
3574
|
+
# and walked straight past the inference->low invariant (fresh-review HIGH).
|
|
3575
|
+
if not (in_evidence and indented):
|
|
3576
|
+
errors.append("type: outside the evidence block")
|
|
3577
|
+
elif etype is not None:
|
|
3578
|
+
errors.append("duplicate type: declaration")
|
|
3579
|
+
else:
|
|
3580
|
+
etype = s[len("type:"):].strip().strip("\x27\x22")
|
|
3581
|
+
in_refs = False
|
|
3582
|
+
elif s.startswith("refs:"):
|
|
3583
|
+
if not (in_evidence and indented):
|
|
3584
|
+
errors.append("refs: outside the evidence block")
|
|
3585
|
+
in_refs = in_evidence and indented
|
|
3586
|
+
elif s.startswith("confidence:") and not indented:
|
|
3587
|
+
if conf is not None:
|
|
3588
|
+
errors.append("duplicate confidence: declaration")
|
|
3589
|
+
else:
|
|
3590
|
+
conf = s[len("confidence:"):].strip().strip("\x27\x22")
|
|
3591
|
+
in_refs = False; in_evidence = False
|
|
3592
|
+
elif in_refs and s.startswith("- "):
|
|
3593
|
+
refs.append(s[2:].strip().strip("\x27\x22"))
|
|
3594
|
+
elif s and not indented:
|
|
3595
|
+
in_refs = False; in_evidence = False
|
|
3596
|
+
if etype not in ("test", "code", "inference"):
|
|
3597
|
+
errors.append("evidence.type %r (expected test|code|inference)" % etype)
|
|
3598
|
+
if not refs:
|
|
3599
|
+
errors.append("evidence.refs is empty (a candidate without evidence is a guess)")
|
|
3600
|
+
if conf not in ("high", "medium", "low"):
|
|
3601
|
+
errors.append("confidence %r (expected high|medium|low)" % conf)
|
|
3602
|
+
if etype == "inference" and conf != "low":
|
|
3603
|
+
errors.append("inference is ALWAYS low confidence (ADR-009); %r declared" % conf)
|
|
3604
|
+
return {"type": etype, "refs": refs, "confidence": conf}, errors
|
|
3605
|
+
|
|
3606
|
+
|
|
3607
|
+
def _resolve_ref(repo_path, ref):
|
|
3608
|
+
"""A ref must point at something REAL: `path`, `path:N`, `path:N-M` or `path#name`.
|
|
3609
|
+
Returns None when it resolves, else the reason."""
|
|
3610
|
+
frag = None
|
|
3611
|
+
if "#" in ref:
|
|
3612
|
+
ref, frag = ref.split("#", 1)
|
|
3613
|
+
span = None
|
|
3614
|
+
m = re.match(r"^(.*?):(\d+)(?:-(\d+))?$", ref)
|
|
3615
|
+
if m:
|
|
3616
|
+
ref = m.group(1)
|
|
3617
|
+
span = (int(m.group(2)), int(m.group(3) or m.group(2)))
|
|
3618
|
+
full = os.path.join(repo_path, ref.replace("/", os.sep))
|
|
3619
|
+
if _gc_rel(full, repo_path) is None:
|
|
3620
|
+
# an absolute path makes os.path.join DISCARD repo_path entirely, and ../ walks
|
|
3621
|
+
# out -- either way the "evidence" would point outside the legacy tree it claims
|
|
3622
|
+
# to evidence (fresh-review HIGH). Confinement is part of resolution.
|
|
3623
|
+
return "ref escapes the repo tree: %s" % ref
|
|
3624
|
+
if not os.path.isfile(full):
|
|
3625
|
+
return "file not found: %s" % ref
|
|
3626
|
+
if span or frag:
|
|
3627
|
+
try:
|
|
3628
|
+
with open(full, encoding="utf-8", errors="replace") as fh:
|
|
3629
|
+
body = fh.read()
|
|
3630
|
+
except OSError as exc:
|
|
3631
|
+
return "unreadable: %s" % exc
|
|
3632
|
+
if span:
|
|
3633
|
+
n = body.count("\n") + 1
|
|
3634
|
+
if span[0] < 1 or span[1] > n or span[0] > span[1]:
|
|
3635
|
+
return "lines %d-%d out of range (file has %d)" % (span[0], span[1], n)
|
|
3636
|
+
if frag and frag not in body:
|
|
3637
|
+
return "fragment %r not found in %s" % (frag, ref)
|
|
3638
|
+
return None
|
|
3639
|
+
|
|
3640
|
+
|
|
3641
|
+
def _load_behavior_ledger(path):
|
|
3642
|
+
"""Strict parse of the verdict table. Returns (rows, errors). Malformed is an ERROR,
|
|
3643
|
+
never a degrade: under the promotion gate, a silent "no verdicts" would UNBLOCK exactly
|
|
3644
|
+
what the gate guards (same posture as golden.scrub.json)."""
|
|
3645
|
+
rows, errors = [], []
|
|
3646
|
+
try:
|
|
3647
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
3648
|
+
lines = fh.read().splitlines()
|
|
3649
|
+
except OSError as exc:
|
|
3650
|
+
return [], ["unreadable: %s" % exc]
|
|
3651
|
+
for n, ln in enumerate(lines, 1):
|
|
3652
|
+
s = ln.strip()
|
|
3653
|
+
if not s.startswith("|"):
|
|
3654
|
+
continue
|
|
3655
|
+
cells = [c.strip() for c in s.strip("|").split("|")]
|
|
3656
|
+
if cells and all(c and set(c) <= set("-: ") for c in cells):
|
|
3657
|
+
continue # separator row (empty cells are NOT)
|
|
3658
|
+
low = [c.lower() for c in cells]
|
|
3659
|
+
if "candidate" in low and "verdict" in low:
|
|
3660
|
+
continue # header row
|
|
3661
|
+
if len(cells) != 6:
|
|
3662
|
+
errors.append("line %d: %d cells, expected 6 (# | candidate | evidence | "
|
|
3663
|
+
"confidence | verdict | adr)" % (n, len(cells)))
|
|
3664
|
+
continue
|
|
3665
|
+
_, cand, _ev, _conf, verdict, adr = cells
|
|
3666
|
+
if verdict not in _BL_VERDICTS:
|
|
3667
|
+
errors.append("line %d: verdict %r is not one of %s -- a fourth state is "
|
|
3668
|
+
"malformation, not an option" % (n, verdict, "/".join(_BL_VERDICTS)))
|
|
3669
|
+
if not re.match(r"^ADR-\S+$", adr):
|
|
3670
|
+
errors.append("line %d: adr ref %r -- no verdict without its why (ADR-010)"
|
|
3671
|
+
% (n, adr))
|
|
3672
|
+
if not cand:
|
|
3673
|
+
errors.append("line %d: empty candidate" % n)
|
|
3674
|
+
rows.append({"candidate": cand, "verdict": verdict, "adr": adr, "line": n})
|
|
3675
|
+
return rows, errors
|
|
3676
|
+
|
|
3677
|
+
|
|
3678
|
+
def _bl_append_only(repo_path, rel):
|
|
3679
|
+
"""The rows in HEAD must be a byte-identical prefix of the working file. Deliberately
|
|
3680
|
+
blunt (ADR-010): an audit trail that tolerates rewriting is not an audit trail.
|
|
3681
|
+
Returns "ok" | "new" | "violation" | "unmeasured"."""
|
|
3682
|
+
try:
|
|
3683
|
+
# probe the repo FIRST: "git failed entirely" and "file not in HEAD yet" are
|
|
3684
|
+
# different answers, and conflating them turned no-git into a silent "new"
|
|
3685
|
+
# (caught by T120's AC-RD-05 -- unmeasured must never be mistaken for anything).
|
|
3686
|
+
probe = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo_path,
|
|
3687
|
+
capture_output=True)
|
|
3688
|
+
if probe.returncode != 0:
|
|
3689
|
+
return "unmeasured"
|
|
3690
|
+
r = subprocess.run(["git", "show", "HEAD:" + rel.replace(os.sep, "/")],
|
|
3691
|
+
cwd=repo_path, capture_output=True)
|
|
3692
|
+
except OSError:
|
|
3693
|
+
return "unmeasured"
|
|
3694
|
+
if r.returncode != 0:
|
|
3695
|
+
return "new" # not in HEAD yet
|
|
3696
|
+
try:
|
|
3697
|
+
with open(os.path.join(repo_path, rel), "rb") as fh:
|
|
3698
|
+
cur = fh.read()
|
|
3699
|
+
except OSError:
|
|
3700
|
+
return "violation" # in HEAD but gone from the tree
|
|
3701
|
+
# normalize line endings on BOTH sides: with core.autocrlf, git stores LF and checks out
|
|
3702
|
+
# CRLF, so a raw byte compare reads that translation as tampering on every Windows box
|
|
3703
|
+
# (found by the first probe). Git itself treats line endings as non-content; so do we.
|
|
3704
|
+
# A verdict edit still cannot hide in a CRLF flip.
|
|
3705
|
+
cur_n = cur.replace(b"\r\n", b"\n")
|
|
3706
|
+
head_n = r.stdout.replace(b"\r\n", b"\n")
|
|
3707
|
+
return "ok" if cur_n.startswith(head_n) else "violation"
|
|
3708
|
+
|
|
3709
|
+
|
|
3710
|
+
def _curation_state(repo_path):
|
|
3711
|
+
"""Everything the gate needs, from one scan. None = feature unused (no discovery/):
|
|
3712
|
+
behavior identical to a release where this code does not exist (AC-RD-07)."""
|
|
3713
|
+
disc = os.path.join(repo_path, CANDIDATE_DIR)
|
|
3714
|
+
if not os.path.isdir(disc):
|
|
3715
|
+
return None
|
|
3716
|
+
cands = sorted(f for f in os.listdir(disc) if f.lower().endswith(".md"))
|
|
3717
|
+
state = {"candidates": [], "malformed": [], "ledger_errors": [],
|
|
3718
|
+
"append_only": None, "unjudged": [], "promote_as_is": [],
|
|
3719
|
+
"promote_with_declared_divergence": [], "excluded": []}
|
|
3720
|
+
for f in cands:
|
|
3721
|
+
data, errs = _parse_candidate(os.path.join(disc, f))
|
|
3722
|
+
if data:
|
|
3723
|
+
for ref in data["refs"]:
|
|
3724
|
+
bad = _resolve_ref(repo_path, ref)
|
|
3725
|
+
if bad:
|
|
3726
|
+
errs.append("ref %r: %s" % (ref, bad))
|
|
3727
|
+
if errs:
|
|
3728
|
+
state["malformed"].append({"candidate": f, "errors": errs})
|
|
3729
|
+
else:
|
|
3730
|
+
state["candidates"].append(f)
|
|
3731
|
+
lpath = os.path.join(repo_path, BEHAVIOR_LEDGER_FILE)
|
|
3732
|
+
verdicts = {}
|
|
3733
|
+
if os.path.isfile(lpath):
|
|
3734
|
+
rows, lerrs = _load_behavior_ledger(lpath)
|
|
3735
|
+
state["ledger_errors"] = lerrs
|
|
3736
|
+
state["append_only"] = _bl_append_only(repo_path, BEHAVIOR_LEDGER_FILE)
|
|
3737
|
+
for row in rows:
|
|
3738
|
+
verdicts[row["candidate"]] = row["verdict"] # append-only: the LATEST row wins
|
|
3739
|
+
for f in state["candidates"]:
|
|
3740
|
+
v = verdicts.get(f)
|
|
3741
|
+
if v is None:
|
|
3742
|
+
state["unjudged"].append(f)
|
|
3743
|
+
elif v == "preserve":
|
|
3744
|
+
state["promote_as_is"].append(f)
|
|
3745
|
+
elif v == "fix":
|
|
3746
|
+
state["promote_with_declared_divergence"].append(f)
|
|
3747
|
+
else:
|
|
3748
|
+
state["excluded"].append(f)
|
|
3749
|
+
return state
|
|
3750
|
+
|
|
3751
|
+
|
|
3752
|
+
def cmd_curation_check(args):
|
|
3753
|
+
"""The INV-CURATION-01 gate, measured. Exit 2: malformation or tampering (config-error
|
|
3754
|
+
class -- candidates that cannot be validated, a ledger that cannot be trusted). Exit 1:
|
|
3755
|
+
valid candidates awaiting a human verdict (the quarantine holding). Exit 0: every
|
|
3756
|
+
candidate judged, or the feature unused."""
|
|
3757
|
+
ledger = _load(args.ledger)
|
|
3758
|
+
_repo_node(ledger, args.repo)
|
|
3759
|
+
repo_path = _repo_cfg(ledger, args.repo).get("path", ".")
|
|
3760
|
+
st = _curation_state(repo_path)
|
|
3761
|
+
if st is None:
|
|
3762
|
+
if args.json:
|
|
3763
|
+
print(json.dumps({"repo": args.repo, "in_use": False}))
|
|
3764
|
+
else:
|
|
3765
|
+
print("CURATION %s: no %s/ directory -- feature unused, nothing to gate."
|
|
3766
|
+
% (args.repo, CANDIDATE_DIR))
|
|
3767
|
+
sys.exit(0)
|
|
3768
|
+
out = dict(st)
|
|
3769
|
+
out.update({"repo": args.repo, "in_use": True})
|
|
3770
|
+
hard = bool(st["malformed"] or st["ledger_errors"]
|
|
3771
|
+
or st["append_only"] == "violation")
|
|
3772
|
+
if args.json:
|
|
3773
|
+
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
3774
|
+
else:
|
|
3775
|
+
print("CURATION %s: %d candidate(s), %d judged, %d awaiting verdict"
|
|
3776
|
+
% (args.repo, len(st["candidates"]) + len(st["malformed"]),
|
|
3777
|
+
len(st["promote_as_is"]) + len(st["promote_with_declared_divergence"])
|
|
3778
|
+
+ len(st["excluded"]), len(st["unjudged"])))
|
|
3779
|
+
for m in st["malformed"]:
|
|
3780
|
+
print(" !! %s: %s" % (m["candidate"], "; ".join(m["errors"])))
|
|
3781
|
+
for e in st["ledger_errors"]:
|
|
3782
|
+
print(" !! %s: %s" % (BEHAVIOR_LEDGER_FILE, e))
|
|
3783
|
+
if st["append_only"] == "violation":
|
|
3784
|
+
print(" !! %s: existing rows were EDITED -- append-only violated; revert and "
|
|
3785
|
+
"add a new row + ADR instead" % BEHAVIOR_LEDGER_FILE)
|
|
3786
|
+
elif st["append_only"] == "unmeasured":
|
|
3787
|
+
print(" -- append-only: UNMEASURED (no git) -- reported, never claimed as pass")
|
|
3788
|
+
for f in st["unjudged"]:
|
|
3789
|
+
print(" .. %s: awaiting human verdict (blocks forward)" % f)
|
|
3790
|
+
sys.exit(2 if hard else (1 if st["unjudged"] else 0))
|
|
3791
|
+
|
|
3792
|
+
|
|
3793
|
+
|
|
3513
3794
|
def cmd_escalate(args):
|
|
3514
3795
|
ledger = _load(args.ledger)
|
|
3515
3796
|
_repo_node(ledger, args.repo)
|
|
@@ -7263,6 +7544,13 @@ def build_parser():
|
|
|
7263
7544
|
pfp.add_argument("--json", action="store_true")
|
|
7264
7545
|
pfp.set_defaults(func=cmd_fastpath_eval)
|
|
7265
7546
|
|
|
7547
|
+
pcu = sub.add_parser("curation-check",
|
|
7548
|
+
help="the INV-CURATION-01 gate: candidates, verdicts, append-only ledger (ADR-009/010)")
|
|
7549
|
+
pcu.add_argument("--ledger", default="QA-LEDGER.json")
|
|
7550
|
+
pcu.add_argument("--repo", required=True)
|
|
7551
|
+
pcu.add_argument("--json", action="store_true")
|
|
7552
|
+
pcu.set_defaults(func=cmd_curation_check)
|
|
7553
|
+
|
|
7266
7554
|
pcr = sub.add_parser("cleanroom",
|
|
7267
7555
|
help="run a command against a CLEAN checkout of one commit in a throwaway worktree (ADR-008)")
|
|
7268
7556
|
pcr.add_argument("--ledger", default="QA-LEDGER.json")
|
|
@@ -5,8 +5,10 @@ description: >
|
|
|
5
5
|
inverse of discovery: the system already exists and its behavior IS the truth, so you
|
|
6
6
|
EXTRACT facts instead of proposing shape. Produce ONLY facts — a system map (endpoints,
|
|
7
7
|
contracts, dependency graph, module candidates via static analysis) and a golden suite
|
|
8
|
-
captured mechanically at the boundaries
|
|
9
|
-
|
|
8
|
+
captured mechanically at the boundaries — plus CANDIDATE specs in quarantine
|
|
9
|
+
(discovery/, evidence + confidence mandatory), which NEVER promote without a human
|
|
10
|
+
verdict in BEHAVIOR-LEDGER.md (ADR-009, INV-CURATION-01: the engine measures the gate).
|
|
11
|
+
Invoke for "reverse-discovery",
|
|
10
12
|
"migrar/modernizar este sistema", "caracterizar el sistema viejo antes de tocarlo".
|
|
11
13
|
allowed-tools: Read, Write, Glob, Grep, Bash
|
|
12
14
|
disable-model-invocation: false
|
|
@@ -15,8 +17,9 @@ disable-model-invocation: false
|
|
|
15
17
|
# reverse-discovery — extract the facts of an existing system before migrating it
|
|
16
18
|
|
|
17
19
|
`uscha-discovery` is greenfield: you only have an idea, so you PROPOSE the shape. This is the
|
|
18
|
-
opposite. The system already runs; its observable behavior is the ground truth. **
|
|
19
|
-
|
|
20
|
+
opposite. The system already runs; its observable behavior is the ground truth. **Facts
|
|
21
|
+
first, always — and what cannot be fact yet becomes a CANDIDATE in quarantine: evidenced,
|
|
22
|
+
confidence-tagged, and promoted to the contract only by a human verdict (ADR-009).**
|
|
20
23
|
|
|
21
24
|
## First contact (show ONCE, then never again)
|
|
22
25
|
|
|
@@ -29,11 +32,11 @@ breadcrumb. Repeating it every run would be exactly the ceremony the method forb
|
|
|
29
32
|
[uscha · reverse-discovery · START]
|
|
30
33
|
Method: you bring the idea, the method builds the rest. Facts block, guesses advise;
|
|
31
34
|
nothing closes on a checkbox, and the human approves the merge.
|
|
32
|
-
Here: I EXTRACT facts from the system that already exists.
|
|
35
|
+
Here: I EXTRACT facts and CANDIDATES from the system that already exists. Candidates stay quarantined until YOUR verdict promotes them.
|
|
33
36
|
Output: SYSTEM-MAP.md · DISCOVERY-SUMMARY.md -- endpoints, contracts, dependency graph,
|
|
34
|
-
module candidates
|
|
37
|
+
module candidates + discovery/ candidates + BEHAVIOR-LEDGER.md. The verdicts are yours.
|
|
35
38
|
Next: `/uscha-characterize` freezes current behavior and a HUMAN approves the golden;
|
|
36
|
-
only
|
|
39
|
+
only judged candidates reach the migration SPEC; the golden stays the oracle.
|
|
37
40
|
Stop: say so at any point -- whatever is already written stays.
|
|
38
41
|
```
|
|
39
42
|
|
|
@@ -88,17 +91,30 @@ and say exactly what unblocks it.
|
|
|
88
91
|
Keep the CONTENT in the conversation's language, but keep the labels (`CLOSED`, `Produced`,
|
|
89
92
|
`Blocks`, `Next`, `Run`) verbatim — they are the method's vocabulary and the smoke checks them.
|
|
90
93
|
|
|
91
|
-
## The one non-negotiable:
|
|
94
|
+
## The one non-negotiable: quarantine, not judgment (ADR-009)
|
|
92
95
|
|
|
93
96
|
A system map (from static analysis) and a golden suite (byte-captured) are FACTS —
|
|
94
|
-
verifiable, not opinions.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
(
|
|
97
|
+
verifiable, not opinions. What you read out of the code beyond that is a CLAIM, and an
|
|
98
|
+
LLM's claim about legacy code is plausible on the surface and divergent from reality —
|
|
99
|
+
the exact blind spot the golden exists to counter. The old rule banned authoring such
|
|
100
|
+
claims outright; ADR-009 renegotiated it: **you may author them as CANDIDATES, in
|
|
101
|
+
quarantine, and you may NEVER judge or promote them.**
|
|
102
|
+
|
|
103
|
+
- Every candidate lives in `discovery/`, with mandatory frontmatter: `evidence.type`
|
|
104
|
+
(`test | code | inference`), `evidence.refs` (real `file:line(s)` — the engine resolves
|
|
105
|
+
them; a ref that does not resolve makes the candidate invalid, named), and `confidence`
|
|
106
|
+
(`inference` is ALWAYS `low`).
|
|
107
|
+
- **You capture; you do not judge.** Never decide whether a behavior is bug or feature —
|
|
108
|
+
that is the verdict (`preserve` / `fix` / `undefined`), it belongs to the human, and it
|
|
109
|
+
lands in `BEHAVIOR-LEDGER.md` with an ADR per verdict. You may present a candidate with
|
|
110
|
+
its evidence and ASK; you may write the skeleton row once the human decides; the verdict
|
|
111
|
+
itself is theirs.
|
|
112
|
+
- The gate is MEASURED, not promised: `qa_ledger.py curation-check` blocks the forward flow
|
|
113
|
+
while any candidate lacks a verdict (INV-CURATION-01) — and a malformed candidate or a
|
|
114
|
+
tampered ledger blocks harder (`exit 2`), because "could not validate" must never read
|
|
115
|
+
as judged.
|
|
116
|
+
- The ledger is append-only (verified against git): reverting a verdict is a NEW row plus a
|
|
117
|
+
new ADR, never an edit.
|
|
102
118
|
|
|
103
119
|
## Phase 1 — Map (fact)
|
|
104
120
|
|
|
@@ -127,7 +143,29 @@ Delegate to the `uscha-characterize` skill; if it is not installed, follow its c
|
|
|
127
143
|
inputs of past bugs. A boundary whose corpus does not exercise its known branches is
|
|
128
144
|
marked **PARTIAL**, never covered.
|
|
129
145
|
|
|
130
|
-
## Phase 3 —
|
|
146
|
+
## Phase 3 — Candidates (claims, quarantined)
|
|
147
|
+
|
|
148
|
+
For every observable behavior the map + golden surface, emit one candidate file in
|
|
149
|
+
`discovery/` (`NNN-short-slug.md`): frontmatter per the section above, then a short
|
|
150
|
+
description of the behavior — what it does, not whether it should. Undesigned edge cases
|
|
151
|
+
are captured too, as `inference`/`low`. Then run:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
python qa_ledger.py curation-check --repo <name>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Echo its output verbatim — it names invalid candidates and everything awaiting verdict.
|
|
158
|
+
The skill wires; the engine measures.
|
|
159
|
+
|
|
160
|
+
## Phase 4 — Curation (the human's verdicts)
|
|
161
|
+
|
|
162
|
+
Present one candidate at a time: the behavior, its evidence refs, its confidence. Ask for
|
|
163
|
+
the verdict. On each answer, append the ledger row (`| # | candidate | evidence |
|
|
164
|
+
confidence | verdict | ADR-RD-NNN |`) and write the skeleton `ADR-RD-NNN` (5-10 lines:
|
|
165
|
+
context, evidence, verdict, consequence) for the human to complete. Re-run `curation-check`
|
|
166
|
+
after the pass: exit 0 means every candidate is judged and the quarantine is clear.
|
|
167
|
+
|
|
168
|
+
## Phase 5 — Summary (facts, no opinion)
|
|
131
169
|
|
|
132
170
|
Write `DISCOVERY-SUMMARY.md`: the system map + the golden coverage report (which boundaries
|
|
133
171
|
are captured and approved, which are PARTIAL and why). This is the fact base the human reads
|
|
@@ -135,8 +173,9 @@ to write the migration SPEC. Do not editorialize.
|
|
|
135
173
|
|
|
136
174
|
## What you do NOT do (the human's job)
|
|
137
175
|
|
|
138
|
-
- Do NOT
|
|
139
|
-
|
|
176
|
+
- Do NOT record a verdict, promote a candidate, or skip the ledger — the quarantine gate
|
|
177
|
+
is the human's, and the engine measures it (INV-CURATION-01).
|
|
178
|
+
- Do NOT write the migration SPEC — only judged candidates feed it, and the human writes it.
|
|
140
179
|
- Do NOT decide the NEW structure (module boundaries, shared kernel, sync vs events). Those
|
|
141
180
|
are forward decisions → `/uscha-adr-refine`.
|
|
142
181
|
|
|
@@ -150,15 +189,17 @@ to write the migration SPEC. Do not editorialize.
|
|
|
150
189
|
## Convergence — finish when
|
|
151
190
|
|
|
152
191
|
The map is complete (every boundary and dependency accounted for, or explicitly marked
|
|
153
|
-
unknown), the golden is captured and **human-approved**,
|
|
192
|
+
unknown), the golden is captured and **human-approved**, every candidate has a verdict
|
|
193
|
+
(`curation-check` exits 0 — measured, not remembered), and the coverage report states
|
|
154
194
|
what is covered vs PARTIAL. State plainly that the facts are ready, then hand off.
|
|
155
195
|
|
|
156
196
|
## Handoff
|
|
157
197
|
|
|
158
|
-
> "Read SYSTEM-MAP.md
|
|
159
|
-
>
|
|
160
|
-
>
|
|
161
|
-
>
|
|
198
|
+
> "Read SYSTEM-MAP.md, DISCOVERY-SUMMARY.md and BEHAVIOR-LEDGER.md, and inspect the
|
|
199
|
+
> approved golden. The facts are measured; the verdicts are yours and recorded. Now write
|
|
200
|
+
> the migration SPEC from the JUDGED candidates — `preserve` == golden must match, `fix` ==
|
|
201
|
+
> divergence declared by its ADR, `undefined` == out of contract — and take the partition
|
|
202
|
+
> decisions via /uscha-adr-refine."
|
|
162
203
|
|
|
163
204
|
Flow (migration): `uscha-reverse-discovery` (facts) → human writes SPEC + `/uscha-adr-refine` (forward
|
|
164
205
|
module decisions) → `/uscha-devloop` (restructure; `golden-diff` + `ApplicationModules.verify()`
|
|
@@ -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.64.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, 34 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"
|
package/uscha-kit/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# uscha-kit
|
|
2
2
|
|
|
3
|
-
**Kit version:** v1.
|
|
3
|
+
**Kit version:** v1.64.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
|
|
4
4
|
|
|
5
5
|
Spec-driven orchestrator + multi-repo QA for Claude Code, with a deterministic ledger.
|
|
6
6
|
**Nine skills** (`uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`,
|
|
@@ -178,6 +178,25 @@ Honest limit: this is **not** a substitute for CI. A local worktree runs on your
|
|
|
178
178
|
OS, your shell - environment variance is invisible to it. Different failure class, different
|
|
179
179
|
instrument.
|
|
180
180
|
|
|
181
|
+
## Curation (ADR-009/010) - candidates in quarantine, verdicts on the record
|
|
182
|
+
|
|
183
|
+
Reverse discovery's brownfield entry: the agent may author CANDIDATE specs of a legacy
|
|
184
|
+
system - in `discovery/`, with mandatory `evidence` (`test|code|inference`) and
|
|
185
|
+
`confidence` frontmatter, refs the engine resolves against real files - and **nothing is
|
|
186
|
+
promoted without a human verdict**:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
python qa_ledger.py curation-check --repo <name> [--json]
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Verdicts live in `BEHAVIOR-LEDGER.md` (`preserve` / `fix` / `undefined`, one ADR each) -
|
|
193
|
+
a human-readable table with machine-enforced rules: strict shape (`exit 2` on malformed,
|
|
194
|
+
because under this gate a silent "no verdicts" would UNBLOCK what it guards), append-only
|
|
195
|
+
verified against git (revert = new row + new ADR, never an edit; latest row wins). While
|
|
196
|
+
any candidate lacks a verdict, `pr-ready` is blocked naming it (INV-CURATION-01) - the
|
|
197
|
+
quarantine is measured, not promised. No `discovery/` directory -> the feature does not
|
|
198
|
+
exist and nothing changes.
|
|
199
|
+
|
|
181
200
|
## End-to-end flow
|
|
182
201
|
|
|
183
202
|
`uscha-discovery` is the front for something new (you only have the idea); `uscha-adr-refine` is the front
|
package/uscha-kit/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
uscha-kit 1.
|
|
1
|
+
uscha-kit 1.64.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"AC-RD-07": true, "AC-RD-01": true, "AC-RD-02": true, "AC-RD-03": true, "AC-RD-06": true, "AC-RD-04": true, "AC-RD-05": true}
|
|
@@ -2634,6 +2634,29 @@ def _derive_phase(ledger, name, node, k, qa_order):
|
|
|
2634
2634
|
# requires clean-room evidence that is GREEN and pinned to the CURRENT HEAD. A new commit
|
|
2635
2635
|
# makes the previous run stale for the gate, the same staleness posture the rest of the
|
|
2636
2636
|
# engine takes: evidence certifies the thing it was measured against, nothing later.
|
|
2637
|
+
# curation gate (ADR-009, INV-CURATION-01). In use the moment discovery/ exists --
|
|
2638
|
+
# creating candidates IS the opt-in. Fail-closed both ways: an unjudged candidate blocks,
|
|
2639
|
+
# and a malformation blocks too, because "could not validate" must never read as judged.
|
|
2640
|
+
_cu_cfg = (_repo_cfg(ledger, name) if name != "integration"
|
|
2641
|
+
else {"path": "."}) # synthetic scope: never in config["repos"]
|
|
2642
|
+
_cu = _curation_state(_cu_cfg.get("path", "."))
|
|
2643
|
+
if _cu is not None:
|
|
2644
|
+
if _cu["malformed"] or _cu["ledger_errors"] or _cu["append_only"] == "violation":
|
|
2645
|
+
_bits = [m["candidate"] for m in _cu["malformed"][:3]]
|
|
2646
|
+
if _cu["ledger_errors"]:
|
|
2647
|
+
_bits.append(BEHAVIOR_LEDGER_FILE + " malformado")
|
|
2648
|
+
if _cu["append_only"] == "violation":
|
|
2649
|
+
_bits.append(BEHAVIOR_LEDGER_FILE + " editado (append-only)")
|
|
2650
|
+
reasons.append("curation invalida: " + "; ".join(_bits)
|
|
2651
|
+
+ " -- corregir antes de avanzar")
|
|
2652
|
+
conv = False
|
|
2653
|
+
elif _cu["unjudged"]:
|
|
2654
|
+
reasons.append("candidata(s) sin veredicto humano: "
|
|
2655
|
+
+ ", ".join(_cu["unjudged"][:3])
|
|
2656
|
+
+ (" (+%d)" % (len(_cu["unjudged"]) - 3)
|
|
2657
|
+
if len(_cu["unjudged"]) > 3 else "")
|
|
2658
|
+
+ " -- INV-CURATION-01: sin juicio no hay promocion")
|
|
2659
|
+
conv = False
|
|
2637
2660
|
_cr = _cr_cfg(ledger)
|
|
2638
2661
|
if _cr and _cr.get("mode") == "final":
|
|
2639
2662
|
_head = None
|
|
@@ -3510,6 +3533,264 @@ def cmd_cleanroom(args):
|
|
|
3510
3533
|
|
|
3511
3534
|
|
|
3512
3535
|
|
|
3536
|
+
# --------------------------------------------------------------------------- #
|
|
3537
|
+
# curation (ADR-009/010: candidates in quarantine, verdicts in the behavior
|
|
3538
|
+
# ledger, and a promotion gate the ENGINE measures -- INV-CURATION-01)
|
|
3539
|
+
# --------------------------------------------------------------------------- #
|
|
3540
|
+
|
|
3541
|
+
BEHAVIOR_LEDGER_FILE = "BEHAVIOR-LEDGER.md"
|
|
3542
|
+
CANDIDATE_DIR = "discovery"
|
|
3543
|
+
_BL_VERDICTS = ("preserve", "fix", "undefined")
|
|
3544
|
+
|
|
3545
|
+
|
|
3546
|
+
def _parse_candidate(path):
|
|
3547
|
+
"""Parse one candidate's frontmatter. Returns (data, errors); a candidate with errors
|
|
3548
|
+
is INVALID and named -- never silently skipped, because a skipped candidate would walk
|
|
3549
|
+
past the promotion gate unjudged."""
|
|
3550
|
+
errors = []
|
|
3551
|
+
try:
|
|
3552
|
+
# utf-8-sig: a BOM-adding editor must not turn a well-formed candidate into a
|
|
3553
|
+
# false "no frontmatter" (fresh-review LOW)
|
|
3554
|
+
with open(path, encoding="utf-8-sig", errors="replace") as fh:
|
|
3555
|
+
lines = fh.read().splitlines()
|
|
3556
|
+
except OSError as exc:
|
|
3557
|
+
return None, ["unreadable: %s" % exc]
|
|
3558
|
+
if not lines or lines[0].strip() != "---":
|
|
3559
|
+
return None, ["no frontmatter (evidence/confidence are mandatory, ADR-009)"]
|
|
3560
|
+
fm, i = [], 1
|
|
3561
|
+
while i < len(lines) and lines[i].strip() != "---":
|
|
3562
|
+
fm.append(lines[i]); i += 1
|
|
3563
|
+
if i >= len(lines):
|
|
3564
|
+
return None, ["frontmatter never closes"]
|
|
3565
|
+
etype, refs, conf, in_refs, in_evidence = None, [], None, False, False
|
|
3566
|
+
for ln in fm:
|
|
3567
|
+
s = ln.strip()
|
|
3568
|
+
indented = ln.startswith((" ", "\t"))
|
|
3569
|
+
if s.startswith("evidence:") and not indented:
|
|
3570
|
+
in_evidence = True; in_refs = False
|
|
3571
|
+
elif s.startswith("type:"):
|
|
3572
|
+
# scope + duplicates are MALFORMATION, not last-value-wins: a stray top-level
|
|
3573
|
+
# type:/confidence: after the evidence block silently overrode the nested one
|
|
3574
|
+
# and walked straight past the inference->low invariant (fresh-review HIGH).
|
|
3575
|
+
if not (in_evidence and indented):
|
|
3576
|
+
errors.append("type: outside the evidence block")
|
|
3577
|
+
elif etype is not None:
|
|
3578
|
+
errors.append("duplicate type: declaration")
|
|
3579
|
+
else:
|
|
3580
|
+
etype = s[len("type:"):].strip().strip("\x27\x22")
|
|
3581
|
+
in_refs = False
|
|
3582
|
+
elif s.startswith("refs:"):
|
|
3583
|
+
if not (in_evidence and indented):
|
|
3584
|
+
errors.append("refs: outside the evidence block")
|
|
3585
|
+
in_refs = in_evidence and indented
|
|
3586
|
+
elif s.startswith("confidence:") and not indented:
|
|
3587
|
+
if conf is not None:
|
|
3588
|
+
errors.append("duplicate confidence: declaration")
|
|
3589
|
+
else:
|
|
3590
|
+
conf = s[len("confidence:"):].strip().strip("\x27\x22")
|
|
3591
|
+
in_refs = False; in_evidence = False
|
|
3592
|
+
elif in_refs and s.startswith("- "):
|
|
3593
|
+
refs.append(s[2:].strip().strip("\x27\x22"))
|
|
3594
|
+
elif s and not indented:
|
|
3595
|
+
in_refs = False; in_evidence = False
|
|
3596
|
+
if etype not in ("test", "code", "inference"):
|
|
3597
|
+
errors.append("evidence.type %r (expected test|code|inference)" % etype)
|
|
3598
|
+
if not refs:
|
|
3599
|
+
errors.append("evidence.refs is empty (a candidate without evidence is a guess)")
|
|
3600
|
+
if conf not in ("high", "medium", "low"):
|
|
3601
|
+
errors.append("confidence %r (expected high|medium|low)" % conf)
|
|
3602
|
+
if etype == "inference" and conf != "low":
|
|
3603
|
+
errors.append("inference is ALWAYS low confidence (ADR-009); %r declared" % conf)
|
|
3604
|
+
return {"type": etype, "refs": refs, "confidence": conf}, errors
|
|
3605
|
+
|
|
3606
|
+
|
|
3607
|
+
def _resolve_ref(repo_path, ref):
|
|
3608
|
+
"""A ref must point at something REAL: `path`, `path:N`, `path:N-M` or `path#name`.
|
|
3609
|
+
Returns None when it resolves, else the reason."""
|
|
3610
|
+
frag = None
|
|
3611
|
+
if "#" in ref:
|
|
3612
|
+
ref, frag = ref.split("#", 1)
|
|
3613
|
+
span = None
|
|
3614
|
+
m = re.match(r"^(.*?):(\d+)(?:-(\d+))?$", ref)
|
|
3615
|
+
if m:
|
|
3616
|
+
ref = m.group(1)
|
|
3617
|
+
span = (int(m.group(2)), int(m.group(3) or m.group(2)))
|
|
3618
|
+
full = os.path.join(repo_path, ref.replace("/", os.sep))
|
|
3619
|
+
if _gc_rel(full, repo_path) is None:
|
|
3620
|
+
# an absolute path makes os.path.join DISCARD repo_path entirely, and ../ walks
|
|
3621
|
+
# out -- either way the "evidence" would point outside the legacy tree it claims
|
|
3622
|
+
# to evidence (fresh-review HIGH). Confinement is part of resolution.
|
|
3623
|
+
return "ref escapes the repo tree: %s" % ref
|
|
3624
|
+
if not os.path.isfile(full):
|
|
3625
|
+
return "file not found: %s" % ref
|
|
3626
|
+
if span or frag:
|
|
3627
|
+
try:
|
|
3628
|
+
with open(full, encoding="utf-8", errors="replace") as fh:
|
|
3629
|
+
body = fh.read()
|
|
3630
|
+
except OSError as exc:
|
|
3631
|
+
return "unreadable: %s" % exc
|
|
3632
|
+
if span:
|
|
3633
|
+
n = body.count("\n") + 1
|
|
3634
|
+
if span[0] < 1 or span[1] > n or span[0] > span[1]:
|
|
3635
|
+
return "lines %d-%d out of range (file has %d)" % (span[0], span[1], n)
|
|
3636
|
+
if frag and frag not in body:
|
|
3637
|
+
return "fragment %r not found in %s" % (frag, ref)
|
|
3638
|
+
return None
|
|
3639
|
+
|
|
3640
|
+
|
|
3641
|
+
def _load_behavior_ledger(path):
|
|
3642
|
+
"""Strict parse of the verdict table. Returns (rows, errors). Malformed is an ERROR,
|
|
3643
|
+
never a degrade: under the promotion gate, a silent "no verdicts" would UNBLOCK exactly
|
|
3644
|
+
what the gate guards (same posture as golden.scrub.json)."""
|
|
3645
|
+
rows, errors = [], []
|
|
3646
|
+
try:
|
|
3647
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
3648
|
+
lines = fh.read().splitlines()
|
|
3649
|
+
except OSError as exc:
|
|
3650
|
+
return [], ["unreadable: %s" % exc]
|
|
3651
|
+
for n, ln in enumerate(lines, 1):
|
|
3652
|
+
s = ln.strip()
|
|
3653
|
+
if not s.startswith("|"):
|
|
3654
|
+
continue
|
|
3655
|
+
cells = [c.strip() for c in s.strip("|").split("|")]
|
|
3656
|
+
if cells and all(c and set(c) <= set("-: ") for c in cells):
|
|
3657
|
+
continue # separator row (empty cells are NOT)
|
|
3658
|
+
low = [c.lower() for c in cells]
|
|
3659
|
+
if "candidate" in low and "verdict" in low:
|
|
3660
|
+
continue # header row
|
|
3661
|
+
if len(cells) != 6:
|
|
3662
|
+
errors.append("line %d: %d cells, expected 6 (# | candidate | evidence | "
|
|
3663
|
+
"confidence | verdict | adr)" % (n, len(cells)))
|
|
3664
|
+
continue
|
|
3665
|
+
_, cand, _ev, _conf, verdict, adr = cells
|
|
3666
|
+
if verdict not in _BL_VERDICTS:
|
|
3667
|
+
errors.append("line %d: verdict %r is not one of %s -- a fourth state is "
|
|
3668
|
+
"malformation, not an option" % (n, verdict, "/".join(_BL_VERDICTS)))
|
|
3669
|
+
if not re.match(r"^ADR-\S+$", adr):
|
|
3670
|
+
errors.append("line %d: adr ref %r -- no verdict without its why (ADR-010)"
|
|
3671
|
+
% (n, adr))
|
|
3672
|
+
if not cand:
|
|
3673
|
+
errors.append("line %d: empty candidate" % n)
|
|
3674
|
+
rows.append({"candidate": cand, "verdict": verdict, "adr": adr, "line": n})
|
|
3675
|
+
return rows, errors
|
|
3676
|
+
|
|
3677
|
+
|
|
3678
|
+
def _bl_append_only(repo_path, rel):
|
|
3679
|
+
"""The rows in HEAD must be a byte-identical prefix of the working file. Deliberately
|
|
3680
|
+
blunt (ADR-010): an audit trail that tolerates rewriting is not an audit trail.
|
|
3681
|
+
Returns "ok" | "new" | "violation" | "unmeasured"."""
|
|
3682
|
+
try:
|
|
3683
|
+
# probe the repo FIRST: "git failed entirely" and "file not in HEAD yet" are
|
|
3684
|
+
# different answers, and conflating them turned no-git into a silent "new"
|
|
3685
|
+
# (caught by T120's AC-RD-05 -- unmeasured must never be mistaken for anything).
|
|
3686
|
+
probe = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo_path,
|
|
3687
|
+
capture_output=True)
|
|
3688
|
+
if probe.returncode != 0:
|
|
3689
|
+
return "unmeasured"
|
|
3690
|
+
r = subprocess.run(["git", "show", "HEAD:" + rel.replace(os.sep, "/")],
|
|
3691
|
+
cwd=repo_path, capture_output=True)
|
|
3692
|
+
except OSError:
|
|
3693
|
+
return "unmeasured"
|
|
3694
|
+
if r.returncode != 0:
|
|
3695
|
+
return "new" # not in HEAD yet
|
|
3696
|
+
try:
|
|
3697
|
+
with open(os.path.join(repo_path, rel), "rb") as fh:
|
|
3698
|
+
cur = fh.read()
|
|
3699
|
+
except OSError:
|
|
3700
|
+
return "violation" # in HEAD but gone from the tree
|
|
3701
|
+
# normalize line endings on BOTH sides: with core.autocrlf, git stores LF and checks out
|
|
3702
|
+
# CRLF, so a raw byte compare reads that translation as tampering on every Windows box
|
|
3703
|
+
# (found by the first probe). Git itself treats line endings as non-content; so do we.
|
|
3704
|
+
# A verdict edit still cannot hide in a CRLF flip.
|
|
3705
|
+
cur_n = cur.replace(b"\r\n", b"\n")
|
|
3706
|
+
head_n = r.stdout.replace(b"\r\n", b"\n")
|
|
3707
|
+
return "ok" if cur_n.startswith(head_n) else "violation"
|
|
3708
|
+
|
|
3709
|
+
|
|
3710
|
+
def _curation_state(repo_path):
|
|
3711
|
+
"""Everything the gate needs, from one scan. None = feature unused (no discovery/):
|
|
3712
|
+
behavior identical to a release where this code does not exist (AC-RD-07)."""
|
|
3713
|
+
disc = os.path.join(repo_path, CANDIDATE_DIR)
|
|
3714
|
+
if not os.path.isdir(disc):
|
|
3715
|
+
return None
|
|
3716
|
+
cands = sorted(f for f in os.listdir(disc) if f.lower().endswith(".md"))
|
|
3717
|
+
state = {"candidates": [], "malformed": [], "ledger_errors": [],
|
|
3718
|
+
"append_only": None, "unjudged": [], "promote_as_is": [],
|
|
3719
|
+
"promote_with_declared_divergence": [], "excluded": []}
|
|
3720
|
+
for f in cands:
|
|
3721
|
+
data, errs = _parse_candidate(os.path.join(disc, f))
|
|
3722
|
+
if data:
|
|
3723
|
+
for ref in data["refs"]:
|
|
3724
|
+
bad = _resolve_ref(repo_path, ref)
|
|
3725
|
+
if bad:
|
|
3726
|
+
errs.append("ref %r: %s" % (ref, bad))
|
|
3727
|
+
if errs:
|
|
3728
|
+
state["malformed"].append({"candidate": f, "errors": errs})
|
|
3729
|
+
else:
|
|
3730
|
+
state["candidates"].append(f)
|
|
3731
|
+
lpath = os.path.join(repo_path, BEHAVIOR_LEDGER_FILE)
|
|
3732
|
+
verdicts = {}
|
|
3733
|
+
if os.path.isfile(lpath):
|
|
3734
|
+
rows, lerrs = _load_behavior_ledger(lpath)
|
|
3735
|
+
state["ledger_errors"] = lerrs
|
|
3736
|
+
state["append_only"] = _bl_append_only(repo_path, BEHAVIOR_LEDGER_FILE)
|
|
3737
|
+
for row in rows:
|
|
3738
|
+
verdicts[row["candidate"]] = row["verdict"] # append-only: the LATEST row wins
|
|
3739
|
+
for f in state["candidates"]:
|
|
3740
|
+
v = verdicts.get(f)
|
|
3741
|
+
if v is None:
|
|
3742
|
+
state["unjudged"].append(f)
|
|
3743
|
+
elif v == "preserve":
|
|
3744
|
+
state["promote_as_is"].append(f)
|
|
3745
|
+
elif v == "fix":
|
|
3746
|
+
state["promote_with_declared_divergence"].append(f)
|
|
3747
|
+
else:
|
|
3748
|
+
state["excluded"].append(f)
|
|
3749
|
+
return state
|
|
3750
|
+
|
|
3751
|
+
|
|
3752
|
+
def cmd_curation_check(args):
|
|
3753
|
+
"""The INV-CURATION-01 gate, measured. Exit 2: malformation or tampering (config-error
|
|
3754
|
+
class -- candidates that cannot be validated, a ledger that cannot be trusted). Exit 1:
|
|
3755
|
+
valid candidates awaiting a human verdict (the quarantine holding). Exit 0: every
|
|
3756
|
+
candidate judged, or the feature unused."""
|
|
3757
|
+
ledger = _load(args.ledger)
|
|
3758
|
+
_repo_node(ledger, args.repo)
|
|
3759
|
+
repo_path = _repo_cfg(ledger, args.repo).get("path", ".")
|
|
3760
|
+
st = _curation_state(repo_path)
|
|
3761
|
+
if st is None:
|
|
3762
|
+
if args.json:
|
|
3763
|
+
print(json.dumps({"repo": args.repo, "in_use": False}))
|
|
3764
|
+
else:
|
|
3765
|
+
print("CURATION %s: no %s/ directory -- feature unused, nothing to gate."
|
|
3766
|
+
% (args.repo, CANDIDATE_DIR))
|
|
3767
|
+
sys.exit(0)
|
|
3768
|
+
out = dict(st)
|
|
3769
|
+
out.update({"repo": args.repo, "in_use": True})
|
|
3770
|
+
hard = bool(st["malformed"] or st["ledger_errors"]
|
|
3771
|
+
or st["append_only"] == "violation")
|
|
3772
|
+
if args.json:
|
|
3773
|
+
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
3774
|
+
else:
|
|
3775
|
+
print("CURATION %s: %d candidate(s), %d judged, %d awaiting verdict"
|
|
3776
|
+
% (args.repo, len(st["candidates"]) + len(st["malformed"]),
|
|
3777
|
+
len(st["promote_as_is"]) + len(st["promote_with_declared_divergence"])
|
|
3778
|
+
+ len(st["excluded"]), len(st["unjudged"])))
|
|
3779
|
+
for m in st["malformed"]:
|
|
3780
|
+
print(" !! %s: %s" % (m["candidate"], "; ".join(m["errors"])))
|
|
3781
|
+
for e in st["ledger_errors"]:
|
|
3782
|
+
print(" !! %s: %s" % (BEHAVIOR_LEDGER_FILE, e))
|
|
3783
|
+
if st["append_only"] == "violation":
|
|
3784
|
+
print(" !! %s: existing rows were EDITED -- append-only violated; revert and "
|
|
3785
|
+
"add a new row + ADR instead" % BEHAVIOR_LEDGER_FILE)
|
|
3786
|
+
elif st["append_only"] == "unmeasured":
|
|
3787
|
+
print(" -- append-only: UNMEASURED (no git) -- reported, never claimed as pass")
|
|
3788
|
+
for f in st["unjudged"]:
|
|
3789
|
+
print(" .. %s: awaiting human verdict (blocks forward)" % f)
|
|
3790
|
+
sys.exit(2 if hard else (1 if st["unjudged"] else 0))
|
|
3791
|
+
|
|
3792
|
+
|
|
3793
|
+
|
|
3513
3794
|
def cmd_escalate(args):
|
|
3514
3795
|
ledger = _load(args.ledger)
|
|
3515
3796
|
_repo_node(ledger, args.repo)
|
|
@@ -7263,6 +7544,13 @@ def build_parser():
|
|
|
7263
7544
|
pfp.add_argument("--json", action="store_true")
|
|
7264
7545
|
pfp.set_defaults(func=cmd_fastpath_eval)
|
|
7265
7546
|
|
|
7547
|
+
pcu = sub.add_parser("curation-check",
|
|
7548
|
+
help="the INV-CURATION-01 gate: candidates, verdicts, append-only ledger (ADR-009/010)")
|
|
7549
|
+
pcu.add_argument("--ledger", default="QA-LEDGER.json")
|
|
7550
|
+
pcu.add_argument("--repo", required=True)
|
|
7551
|
+
pcu.add_argument("--json", action="store_true")
|
|
7552
|
+
pcu.set_defaults(func=cmd_curation_check)
|
|
7553
|
+
|
|
7266
7554
|
pcr = sub.add_parser("cleanroom",
|
|
7267
7555
|
help="run a command against a CLEAN checkout of one commit in a throwaway worktree (ADR-008)")
|
|
7268
7556
|
pcr.add_argument("--ledger", default="QA-LEDGER.json")
|
|
@@ -5,8 +5,10 @@ description: >
|
|
|
5
5
|
inverse of discovery: the system already exists and its behavior IS the truth, so you
|
|
6
6
|
EXTRACT facts instead of proposing shape. Produce ONLY facts — a system map (endpoints,
|
|
7
7
|
contracts, dependency graph, module candidates via static analysis) and a golden suite
|
|
8
|
-
captured mechanically at the boundaries
|
|
9
|
-
|
|
8
|
+
captured mechanically at the boundaries — plus CANDIDATE specs in quarantine
|
|
9
|
+
(discovery/, evidence + confidence mandatory), which NEVER promote without a human
|
|
10
|
+
verdict in BEHAVIOR-LEDGER.md (ADR-009, INV-CURATION-01: the engine measures the gate).
|
|
11
|
+
Invoke for "reverse-discovery",
|
|
10
12
|
"migrar/modernizar este sistema", "caracterizar el sistema viejo antes de tocarlo".
|
|
11
13
|
allowed-tools: Read, Write, Glob, Grep, Bash
|
|
12
14
|
disable-model-invocation: false
|
|
@@ -15,8 +17,9 @@ disable-model-invocation: false
|
|
|
15
17
|
# reverse-discovery — extract the facts of an existing system before migrating it
|
|
16
18
|
|
|
17
19
|
`uscha-discovery` is greenfield: you only have an idea, so you PROPOSE the shape. This is the
|
|
18
|
-
opposite. The system already runs; its observable behavior is the ground truth. **
|
|
19
|
-
|
|
20
|
+
opposite. The system already runs; its observable behavior is the ground truth. **Facts
|
|
21
|
+
first, always — and what cannot be fact yet becomes a CANDIDATE in quarantine: evidenced,
|
|
22
|
+
confidence-tagged, and promoted to the contract only by a human verdict (ADR-009).**
|
|
20
23
|
|
|
21
24
|
## First contact (show ONCE, then never again)
|
|
22
25
|
|
|
@@ -29,11 +32,11 @@ breadcrumb. Repeating it every run would be exactly the ceremony the method forb
|
|
|
29
32
|
[uscha · reverse-discovery · START]
|
|
30
33
|
Method: you bring the idea, the method builds the rest. Facts block, guesses advise;
|
|
31
34
|
nothing closes on a checkbox, and the human approves the merge.
|
|
32
|
-
Here: I EXTRACT facts from the system that already exists.
|
|
35
|
+
Here: I EXTRACT facts and CANDIDATES from the system that already exists. Candidates stay quarantined until YOUR verdict promotes them.
|
|
33
36
|
Output: SYSTEM-MAP.md · DISCOVERY-SUMMARY.md -- endpoints, contracts, dependency graph,
|
|
34
|
-
module candidates
|
|
37
|
+
module candidates + discovery/ candidates + BEHAVIOR-LEDGER.md. The verdicts are yours.
|
|
35
38
|
Next: `/uscha-characterize` freezes current behavior and a HUMAN approves the golden;
|
|
36
|
-
only
|
|
39
|
+
only judged candidates reach the migration SPEC; the golden stays the oracle.
|
|
37
40
|
Stop: say so at any point -- whatever is already written stays.
|
|
38
41
|
```
|
|
39
42
|
|
|
@@ -88,17 +91,30 @@ and say exactly what unblocks it.
|
|
|
88
91
|
Keep the CONTENT in the conversation's language, but keep the labels (`CLOSED`, `Produced`,
|
|
89
92
|
`Blocks`, `Next`, `Run`) verbatim — they are the method's vocabulary and the smoke checks them.
|
|
90
93
|
|
|
91
|
-
## The one non-negotiable:
|
|
94
|
+
## The one non-negotiable: quarantine, not judgment (ADR-009)
|
|
92
95
|
|
|
93
96
|
A system map (from static analysis) and a golden suite (byte-captured) are FACTS —
|
|
94
|
-
verifiable, not opinions.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
(
|
|
97
|
+
verifiable, not opinions. What you read out of the code beyond that is a CLAIM, and an
|
|
98
|
+
LLM's claim about legacy code is plausible on the surface and divergent from reality —
|
|
99
|
+
the exact blind spot the golden exists to counter. The old rule banned authoring such
|
|
100
|
+
claims outright; ADR-009 renegotiated it: **you may author them as CANDIDATES, in
|
|
101
|
+
quarantine, and you may NEVER judge or promote them.**
|
|
102
|
+
|
|
103
|
+
- Every candidate lives in `discovery/`, with mandatory frontmatter: `evidence.type`
|
|
104
|
+
(`test | code | inference`), `evidence.refs` (real `file:line(s)` — the engine resolves
|
|
105
|
+
them; a ref that does not resolve makes the candidate invalid, named), and `confidence`
|
|
106
|
+
(`inference` is ALWAYS `low`).
|
|
107
|
+
- **You capture; you do not judge.** Never decide whether a behavior is bug or feature —
|
|
108
|
+
that is the verdict (`preserve` / `fix` / `undefined`), it belongs to the human, and it
|
|
109
|
+
lands in `BEHAVIOR-LEDGER.md` with an ADR per verdict. You may present a candidate with
|
|
110
|
+
its evidence and ASK; you may write the skeleton row once the human decides; the verdict
|
|
111
|
+
itself is theirs.
|
|
112
|
+
- The gate is MEASURED, not promised: `qa_ledger.py curation-check` blocks the forward flow
|
|
113
|
+
while any candidate lacks a verdict (INV-CURATION-01) — and a malformed candidate or a
|
|
114
|
+
tampered ledger blocks harder (`exit 2`), because "could not validate" must never read
|
|
115
|
+
as judged.
|
|
116
|
+
- The ledger is append-only (verified against git): reverting a verdict is a NEW row plus a
|
|
117
|
+
new ADR, never an edit.
|
|
102
118
|
|
|
103
119
|
## Phase 1 — Map (fact)
|
|
104
120
|
|
|
@@ -127,7 +143,29 @@ Delegate to the `uscha-characterize` skill; if it is not installed, follow its c
|
|
|
127
143
|
inputs of past bugs. A boundary whose corpus does not exercise its known branches is
|
|
128
144
|
marked **PARTIAL**, never covered.
|
|
129
145
|
|
|
130
|
-
## Phase 3 —
|
|
146
|
+
## Phase 3 — Candidates (claims, quarantined)
|
|
147
|
+
|
|
148
|
+
For every observable behavior the map + golden surface, emit one candidate file in
|
|
149
|
+
`discovery/` (`NNN-short-slug.md`): frontmatter per the section above, then a short
|
|
150
|
+
description of the behavior — what it does, not whether it should. Undesigned edge cases
|
|
151
|
+
are captured too, as `inference`/`low`. Then run:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
python qa_ledger.py curation-check --repo <name>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Echo its output verbatim — it names invalid candidates and everything awaiting verdict.
|
|
158
|
+
The skill wires; the engine measures.
|
|
159
|
+
|
|
160
|
+
## Phase 4 — Curation (the human's verdicts)
|
|
161
|
+
|
|
162
|
+
Present one candidate at a time: the behavior, its evidence refs, its confidence. Ask for
|
|
163
|
+
the verdict. On each answer, append the ledger row (`| # | candidate | evidence |
|
|
164
|
+
confidence | verdict | ADR-RD-NNN |`) and write the skeleton `ADR-RD-NNN` (5-10 lines:
|
|
165
|
+
context, evidence, verdict, consequence) for the human to complete. Re-run `curation-check`
|
|
166
|
+
after the pass: exit 0 means every candidate is judged and the quarantine is clear.
|
|
167
|
+
|
|
168
|
+
## Phase 5 — Summary (facts, no opinion)
|
|
131
169
|
|
|
132
170
|
Write `DISCOVERY-SUMMARY.md`: the system map + the golden coverage report (which boundaries
|
|
133
171
|
are captured and approved, which are PARTIAL and why). This is the fact base the human reads
|
|
@@ -135,8 +173,9 @@ to write the migration SPEC. Do not editorialize.
|
|
|
135
173
|
|
|
136
174
|
## What you do NOT do (the human's job)
|
|
137
175
|
|
|
138
|
-
- Do NOT
|
|
139
|
-
|
|
176
|
+
- Do NOT record a verdict, promote a candidate, or skip the ledger — the quarantine gate
|
|
177
|
+
is the human's, and the engine measures it (INV-CURATION-01).
|
|
178
|
+
- Do NOT write the migration SPEC — only judged candidates feed it, and the human writes it.
|
|
140
179
|
- Do NOT decide the NEW structure (module boundaries, shared kernel, sync vs events). Those
|
|
141
180
|
are forward decisions → `/uscha-adr-refine`.
|
|
142
181
|
|
|
@@ -150,15 +189,17 @@ to write the migration SPEC. Do not editorialize.
|
|
|
150
189
|
## Convergence — finish when
|
|
151
190
|
|
|
152
191
|
The map is complete (every boundary and dependency accounted for, or explicitly marked
|
|
153
|
-
unknown), the golden is captured and **human-approved**,
|
|
192
|
+
unknown), the golden is captured and **human-approved**, every candidate has a verdict
|
|
193
|
+
(`curation-check` exits 0 — measured, not remembered), and the coverage report states
|
|
154
194
|
what is covered vs PARTIAL. State plainly that the facts are ready, then hand off.
|
|
155
195
|
|
|
156
196
|
## Handoff
|
|
157
197
|
|
|
158
|
-
> "Read SYSTEM-MAP.md
|
|
159
|
-
>
|
|
160
|
-
>
|
|
161
|
-
>
|
|
198
|
+
> "Read SYSTEM-MAP.md, DISCOVERY-SUMMARY.md and BEHAVIOR-LEDGER.md, and inspect the
|
|
199
|
+
> approved golden. The facts are measured; the verdicts are yours and recorded. Now write
|
|
200
|
+
> the migration SPEC from the JUDGED candidates — `preserve` == golden must match, `fix` ==
|
|
201
|
+
> divergence declared by its ADR, `undefined` == out of contract — and take the partition
|
|
202
|
+
> decisions via /uscha-adr-refine."
|
|
162
203
|
|
|
163
204
|
Flow (migration): `uscha-reverse-discovery` (facts) → human writes SPEC + `/uscha-adr-refine` (forward
|
|
164
205
|
module decisions) → `/uscha-devloop` (restructure; `golden-diff` + `ApplicationModules.verify()`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Behavior Ledger
|
|
2
|
+
|
|
3
|
+
The append-only audit trail of every verdict on the legacy system's observed behavior
|
|
4
|
+
(ADR-009/010). Rules the engine enforces (`qa_ledger.py curation-check`):
|
|
5
|
+
|
|
6
|
+
- Exactly six columns per row. Verdict is one of `preserve` / `fix` / `undefined` — anything
|
|
7
|
+
else is malformation, not a fourth state.
|
|
8
|
+
- Every verdict names its ADR (`ADR-RD-NNN`): no verdict without its why.
|
|
9
|
+
- Append-only, verified against git: reverting a verdict is a NEW row plus a new ADR, never
|
|
10
|
+
an edit. The LATEST row for a candidate wins.
|
|
11
|
+
|
|
12
|
+
| # | candidate | evidence | confidence | verdict | adr |
|
|
13
|
+
|---|-----------|----------|------------|---------|-----|
|