@andresmassello/uscha 1.71.0 → 1.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,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.71.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
43
+ **Kit v1.72.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`, 40 subcommands, Python stdlib) that ingests
79
+ **A measurement engine** (`qa_ledger.py`, 42 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.71.0",
3
+ "version": "1.72.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",
@@ -4598,8 +4598,25 @@ def cmd_fidelity(args):
4598
4598
  % (_scope, okc, len(static_canon)))
4599
4599
  else:
4600
4600
  dims["contracts"] = _fid_dim(None, "UNMEASURED: no static-class canonical items")
4601
- # curation_closure: curated OBS / total OBS in the active delta
4602
- if obs:
4601
+ # curation_closure: curated OBS / total OBS in the active delta. With --ir (ADR-015) it is
4602
+ # answered as a path query over the graph -- OBS nodes reachable to a CURATION node via an
4603
+ # OBS->CURATION edge / OBS nodes. It reproduces v0 when the graph's OBS set equals the
4604
+ # delta's (the FIELD-RUN-001 case); a canonical carrying OBS beyond the active delta would
4605
+ # widen the denominator, which is the graph's honest answer, not v0's.
4606
+ if args.ir:
4607
+ graph = _extract_ir(repo_path, ledger)
4608
+ obs_nodes = [nd["id"] for nd in graph["nodes"] if nd["type"] == "OBS"]
4609
+ cured = {e["from"] for e in graph["edges"] if e["type"] == "OBS->CURATION"}
4610
+ if obs_nodes:
4611
+ hit = sum(1 for oid in obs_nodes if oid in cured)
4612
+ dims["curation_closure"] = _fid_dim(
4613
+ round(hit / len(obs_nodes), 4),
4614
+ "IR path query: %d/%d OBS node(s) reach a CURATION node (IR v%s)"
4615
+ % (hit, len(obs_nodes), IR_SCHEMA))
4616
+ else:
4617
+ dims["curation_closure"] = _fid_dim(
4618
+ None, "UNMEASURED: no OBS nodes in the IR graph")
4619
+ elif obs:
4603
4620
  cur = sum(1 for o in obs if o["id"] in verdicts)
4604
4621
  dims["curation_closure"] = _fid_dim(round(cur / len(obs), 4),
4605
4622
  "%d/%d OBS in %s carry a ledger verdict"
@@ -4655,6 +4672,299 @@ def cmd_fidelity(args):
4655
4672
  sys.exit(0)
4656
4673
 
4657
4674
 
4675
+ # --------------------------------------------------------------------------- #
4676
+ # IR (Diamond M2: the canonical package extracts into a typed graph. ADR-015.
4677
+ # Markdown stays canonical; the IR is a derived index. What cannot be typed
4678
+ # deterministically is UNTYPED -- visible, counted, never guessed.)
4679
+ # --------------------------------------------------------------------------- #
4680
+
4681
+ IR_DIR = "ir"
4682
+ IR_FILE = "IR.json"
4683
+ IR_TWIN = "IR.md"
4684
+ IR_SCHEMA = "0.1"
4685
+ IR_NODE_TYPES = ("REQ", "INV", "AC", "CONTRACT", "DECISION", "NFR",
4686
+ "GOLDEN", "OBS", "CURATION", "EVIDENCE")
4687
+ IR_EDGE_TYPES = ("REQ->AC", "AC->EVIDENCE", "DECISION->INV", "OBS->CURATION",
4688
+ "CURATION->canonical", "supersedes", "derived_from")
4689
+ # broader than _AC_ID: the forward package uses sub-namespaced ids (AC-DD-07, AC-FV-06)
4690
+ _IR_AC_LINE = re.compile(r"^\s*[-*]\s*\[[ xX]\]\s*(AC-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)\b\s*[-—:]*\s*(.*)$")
4691
+ _IR_INV_LINE = re.compile(r"^\s*[-*]\s*\*\*(INV-[A-Za-z0-9-]+)\s*[—-]+\s*(.*?)\*\*")
4692
+ _IR_ADR_REF = re.compile(r"\bADR-0*(\d+)\b")
4693
+ _IR_INV_REF = re.compile(r"\bINV-[A-Za-z0-9-]+\b")
4694
+ _IR_SUPERSEDE = re.compile(r"(?i)supersed\w*\s+(ADR-0*\d+)")
4695
+ _IR_BANNER = ("GENERATED by qa_ledger.py ir-extract (ADR-015). Rendered view of "
4696
+ + IR_FILE + " -- hand edits are overwritten on regeneration.")
4697
+
4698
+
4699
+ def _ir_node_id(ntype, text, source):
4700
+ """Content-address the ID-less (ADR-015 option B): nodes with a native human id keep it;
4701
+ only nodes with nothing human to anchor to get NODE-sha256(type+text+source)[:12]."""
4702
+ norm = re.sub(r"\s+", " ", (text or "").strip().lower())
4703
+ return "NODE-" + hashlib.sha256(
4704
+ (ntype + "\n" + norm + "\n" + source).encode("utf-8")).hexdigest()[:12]
4705
+
4706
+
4707
+ def _ir_read_lines(path):
4708
+ try:
4709
+ with open(path, encoding="utf-8-sig", errors="replace") as fh:
4710
+ return fh.read().splitlines()
4711
+ except OSError:
4712
+ return None
4713
+
4714
+
4715
+ def _extract_ir(repo_path, ledger):
4716
+ """Deterministic extraction of the forward canonical package into a typed graph. Every
4717
+ node/edge is derived from an EXISTING structural convention or reference -- nothing is
4718
+ inferred. A line that fills a structural slot but cannot be typed lands in `untyped`."""
4719
+ nodes, edges, untyped = [], [], []
4720
+ seen_ids = set()
4721
+
4722
+ def add(node):
4723
+ if node["id"] not in seen_ids:
4724
+ seen_ids.add(node["id"])
4725
+ nodes.append(node)
4726
+
4727
+ # AC nodes <- ACCEPTANCE.md checkboxes carrying an AC id; a checkbox WITHOUT an id is an
4728
+ # acceptance slot the conventions do not type -> untyped (never a guessed id).
4729
+ acc_lines = _ir_read_lines(os.path.join(repo_path, "ACCEPTANCE.md")) or []
4730
+ for n, ln in enumerate(acc_lines, 1):
4731
+ s = ln.strip()
4732
+ if s[:5].lower() not in ("- [x]", "- [ ]", "* [x]", "* [ ]"):
4733
+ continue
4734
+ m = _IR_AC_LINE.match(ln)
4735
+ src = {"file": "ACCEPTANCE.md", "line": n}
4736
+ if m:
4737
+ add({"id": m.group(1).upper(), "type": "AC",
4738
+ "statement": m.group(2).strip(), "source": src})
4739
+ else:
4740
+ untyped.append({"text": s[5:].strip(), "source": src,
4741
+ "reason": "acceptance checkbox without a traceable AC-id"})
4742
+
4743
+ # INV nodes <- CONSTITUTION.md `- **INV-XXX-NN — Title.**` headings
4744
+ con_lines = _ir_read_lines(os.path.join(repo_path, "CONSTITUTION.md")) or []
4745
+ inv_ids = set()
4746
+ for n, ln in enumerate(con_lines, 1):
4747
+ m = _IR_INV_LINE.match(ln)
4748
+ if m:
4749
+ add({"id": m.group(1).upper(), "type": "INV",
4750
+ "statement": m.group(2).strip().rstrip("."),
4751
+ "source": {"file": "CONSTITUTION.md", "line": n}})
4752
+ inv_ids.add(m.group(1).upper())
4753
+
4754
+ # DECISION nodes <- docs/adr/*.md (native ADR-NNN id, title, file); edges to the INVs the
4755
+ # ADR governs/states, and supersedes edges -- both from references already in the body.
4756
+ adr_dir = os.path.join(repo_path, "docs", "adr")
4757
+ ac_ids = {nd["id"] for nd in nodes if nd["type"] == "AC"}
4758
+ for row in _mirador_adrs(adr_dir):
4759
+ add({"id": row["id"], "type": "DECISION", "statement": row["t"],
4760
+ "source": {"file": os.path.relpath(row["file"], repo_path).replace(os.sep, "/"),
4761
+ "line": 1}})
4762
+ body = _ir_read_lines(row["file"]) or []
4763
+ text = "\n".join(body)
4764
+ for inv in set(_IR_INV_REF.findall(text)):
4765
+ if inv.upper() in inv_ids:
4766
+ edges.append({"from": row["id"], "to": inv.upper(), "type": "DECISION->INV"})
4767
+ for sup in set(_IR_SUPERSEDE.findall(text)):
4768
+ edges.append({"from": row["id"],
4769
+ "to": "ADR-%03d" % int(re.search(r"\d+", sup).group()),
4770
+ "type": "supersedes"})
4771
+ # an ADR's Verification checklist references its ACs -> REQ->AC is absent here (no REQ
4772
+ # layer yet), but AC nodes referenced by an ADR are real edges DECISION mentions AC:
4773
+ for aid in {m.group(0)[1:-1].upper()
4774
+ for m in re.finditer(r"\(AC-[A-Za-z0-9-]+\)", text)}:
4775
+ if aid in ac_ids: # set(): an AC cited twice is one edge
4776
+ edges.append({"from": row["id"], "to": aid, "type": "REQ->AC"})
4777
+
4778
+ # GOLDEN nodes <- git-tracked approved fixtures (the path is a native, stable id)
4779
+ tracked = _tracked_files(repo_path) or []
4780
+ gold_marker = ".appro" + "ved."
4781
+ for f in tracked:
4782
+ if gold_marker in f:
4783
+ add({"id": f, "type": "GOLDEN", "statement": "approved golden fixture",
4784
+ "source": {"file": f, "line": 1}})
4785
+
4786
+ # OBS / CURATION <- the active delta's observations (all of them, so curation_closure is
4787
+ # a real path query over the graph) + the ledger's curation records. Absent in a repo
4788
+ # that never ran a field run -> simply no such nodes.
4789
+ delta, derrs = _load_delta(repo_path)
4790
+ if delta and not derrs:
4791
+ for o in delta.get("observations") or []:
4792
+ prov = (o.get("provenance") or {}).get("files") or []
4793
+ add({"id": o["id"], "type": "OBS", "statement": o.get("statement") or "",
4794
+ "source": {"file": prov[0].split(":")[0] if prov else "delta", "line": 1}})
4795
+ canon_path = os.path.join(repo_path, CANDIDATE_DIR, CANONICAL_FILE)
4796
+ canon_items = []
4797
+ if os.path.isfile(canon_path):
4798
+ try:
4799
+ with open(canon_path, encoding="utf-8-sig") as fh:
4800
+ canon_items = json.load(fh).get("items") or []
4801
+ except (OSError, ValueError):
4802
+ canon_items = []
4803
+ for it in canon_items:
4804
+ oid = it.get("derived_from")
4805
+ if not oid:
4806
+ continue
4807
+ prov = (it.get("provenance") or {}).get("files") or []
4808
+ add({"id": oid, "type": "OBS", "statement": it.get("statement") or "",
4809
+ "source": {"file": prov[0].split(":")[0] if prov else "canonical", "line": 1}})
4810
+ for rec in ledger.get("curation") or []:
4811
+ oid = rec.get("obs_id")
4812
+ if not oid:
4813
+ continue
4814
+ cid = "CUR-" + hashlib.sha256(
4815
+ (oid + "\n" + (rec.get("at") or "")).encode("utf-8")).hexdigest()[:12]
4816
+ add({"id": cid, "type": "CURATION",
4817
+ "statement": "%s: %s" % (rec.get("verdict"), oid),
4818
+ "source": {"file": "QA-LEDGER.json", "line": 0}})
4819
+ if oid in seen_ids:
4820
+ edges.append({"from": oid, "to": cid, "type": "OBS->CURATION"})
4821
+
4822
+ node_ids = {nd["id"] for nd in nodes}
4823
+ kept, dropped = [], 0
4824
+ for e in edges:
4825
+ if e["from"] in node_ids and e["to"] in node_ids:
4826
+ kept.append(e)
4827
+ elif e["type"] == "supersedes" and e["from"] in node_ids:
4828
+ kept.append(e) # a superseded ADR may be archived; keep it
4829
+ else:
4830
+ dropped += 1
4831
+ nodes.sort(key=lambda nd: (nd["type"], nd["id"]))
4832
+ kept.sort(key=lambda e: (e["type"], e["from"], e["to"]))
4833
+ untyped.sort(key=lambda u: (u["source"]["file"], u["source"]["line"]))
4834
+ counts = {t: sum(1 for nd in nodes if nd["type"] == t) for t in IR_NODE_TYPES}
4835
+ stats = {"nodes": len(nodes), "edges": len(kept),
4836
+ "edges_dropped": dropped, "untyped": len(untyped),
4837
+ "by_type": {t: c for t, c in counts.items() if c},
4838
+ "untyped_rate": round(len(untyped) / (len(nodes) + len(untyped)), 4)
4839
+ if (nodes or untyped) else 0.0}
4840
+ graph = {"schema_version": IR_SCHEMA,
4841
+ "_generated_by": "qa_ledger.py ir-extract (ADR-015) -- derived index of the "
4842
+ "canonical package; never hand-edit",
4843
+ "nodes": nodes, "edges": kept, "untyped": untyped, "stats": stats}
4844
+ # the seal covers stats too (fresh-review MEDIUM): a doctored summary that ir-render
4845
+ # would faithfully print must trip the strict loader, not slip past it -- the
4846
+ # derived-but-unsealed lesson, applied a third time (path @1.70, evidence_class @1.69).
4847
+ graph["_integrity"] = _ir_seal(graph)
4848
+ return graph
4849
+
4850
+
4851
+ def _ir_seal(graph):
4852
+ return _integrity_hash({k: graph.get(k) for k in
4853
+ ("schema_version", "nodes", "edges", "untyped", "stats")})
4854
+
4855
+
4856
+ def _load_ir(repo_path):
4857
+ """Strict loader: an unknown schema_version or a broken graph is exit-2 class, never
4858
+ mis-read (AC-IR-05). Returns (graph, errors); graph None when absent."""
4859
+ path = os.path.join(repo_path, IR_DIR, IR_FILE)
4860
+ if not os.path.isfile(path):
4861
+ return None, []
4862
+ try:
4863
+ with open(path, encoding="utf-8-sig") as fh:
4864
+ g = json.load(fh)
4865
+ except (OSError, ValueError) as exc:
4866
+ return {}, ["unreadable: %s" % exc]
4867
+ errors = []
4868
+ if g.get("schema_version") != IR_SCHEMA:
4869
+ errors.append("schema_version %r != %r (a version this engine does not know is not "
4870
+ "read, it is refused)" % (g.get("schema_version"), IR_SCHEMA))
4871
+ return g, errors
4872
+ if not isinstance(g.get("nodes"), list) or not isinstance(g.get("edges"), list):
4873
+ return g, ["nodes/edges missing or not lists"]
4874
+ if g.get("_integrity") != _ir_seal(g):
4875
+ errors.append("integrity seal does not match the graph -- nodes, edges, untyped or "
4876
+ "stats was hand-edited (regenerate via ir-extract)")
4877
+ return g, errors
4878
+
4879
+
4880
+ def _render_ir_md(graph):
4881
+ st = graph.get("stats") or {}
4882
+ lines = ["<!-- %s -->" % _IR_BANNER, "", "# Uscha IR v%s (rendered view)" % IR_SCHEMA, "",
4883
+ "%d nodes · %d edges · %d UNTYPED (rate %.2f)"
4884
+ % (st.get("nodes", 0), st.get("edges", 0), st.get("untyped", 0),
4885
+ st.get("untyped_rate", 0.0)), "",
4886
+ "## Nodes", "", "| id | type | statement | source |",
4887
+ "|----|------|-----------|--------|"]
4888
+ for nd in graph.get("nodes") or []:
4889
+ src = "%s:%s" % (nd["source"]["file"], nd["source"]["line"])
4890
+ stmt = (nd.get("statement") or "").replace("|", "\\|")
4891
+ lines.append("| %s | %s | %s | %s |" % (nd["id"], nd["type"], stmt, src))
4892
+ lines += ["", "## Edges", "", "| from | type | to |", "|------|------|----|"]
4893
+ for e in graph.get("edges") or []:
4894
+ lines.append("| %s | %s | %s |" % (e["from"], e["type"], e["to"]))
4895
+ if graph.get("untyped"):
4896
+ lines += ["", "## UNTYPED (conventions the human layer is missing)", "",
4897
+ "| text | source | reason |", "|------|--------|--------|"]
4898
+ for u in graph["untyped"]:
4899
+ src = "%s:%s" % (u["source"]["file"], u["source"]["line"])
4900
+ txt = (u.get("text") or "").replace("|", "\\|")[:80]
4901
+ lines.append("| %s | %s | %s |" % (txt, src, u.get("reason", "")))
4902
+ lines.append("")
4903
+ return "\n".join(lines)
4904
+
4905
+
4906
+ def cmd_ir_extract(args):
4907
+ ledger = _load(args.ledger)
4908
+ _repo_node(ledger, args.repo)
4909
+ repo_path = _scope_path(ledger, args.repo)
4910
+ graph = _extract_ir(repo_path, ledger)
4911
+ ir_dir = os.path.join(repo_path, IR_DIR)
4912
+ os.makedirs(ir_dir, exist_ok=True)
4913
+ with open(os.path.join(ir_dir, IR_FILE), "w", encoding="utf-8", newline="\n") as fh:
4914
+ fh.write(json.dumps(graph, indent=2, ensure_ascii=False) + "\n")
4915
+ twin_path = os.path.join(ir_dir, IR_TWIN)
4916
+ twin = _render_ir_md(graph)
4917
+ prev = None
4918
+ if os.path.isfile(twin_path):
4919
+ try:
4920
+ with open(twin_path, encoding="utf-8-sig") as fh:
4921
+ prev = fh.read()
4922
+ except OSError:
4923
+ prev = None
4924
+ with open(twin_path, "w", encoding="utf-8", newline="\n") as fh:
4925
+ fh.write(twin)
4926
+ if prev is not None and prev != twin:
4927
+ print("[qa_ledger] ir-extract: %s regenerated (rendered view, never a source)"
4928
+ % IR_TWIN, file=sys.stderr)
4929
+ st = graph["stats"]
4930
+ if args.json:
4931
+ print(json.dumps(st, indent=2, ensure_ascii=False))
4932
+ else:
4933
+ print("IR-EXTRACT %s: %d nodes, %d edges -> %s"
4934
+ % (args.repo, st["nodes"], st["edges"],
4935
+ os.path.join(IR_DIR, IR_FILE)))
4936
+ print(" by type: " + ", ".join("%s=%d" % (t, c)
4937
+ for t, c in st["by_type"].items()))
4938
+ print(" UNTYPED: %d (rate %.2f) -- the size of what the conventions cannot yet type"
4939
+ % (st["untyped"], st["untyped_rate"]))
4940
+ if st["edges_dropped"]:
4941
+ print(" %d edge(s) dropped (endpoint not a node) -- counted, never dangling"
4942
+ % st["edges_dropped"])
4943
+ sys.exit(0)
4944
+
4945
+
4946
+ def cmd_ir_render(args):
4947
+ ledger = _load(args.ledger)
4948
+ _repo_node(ledger, args.repo)
4949
+ repo_path = _scope_path(ledger, args.repo)
4950
+ graph, errors = _load_ir(repo_path)
4951
+ if graph is None:
4952
+ print("[qa_ledger] ir-render: no %s -- run ir-extract first."
4953
+ % os.path.join(IR_DIR, IR_FILE), file=sys.stderr)
4954
+ sys.exit(2)
4955
+ if errors:
4956
+ for e in errors:
4957
+ print("[qa_ledger] ir-render: %s" % e, file=sys.stderr)
4958
+ sys.exit(2)
4959
+ twin = _render_ir_md(graph)
4960
+ with open(os.path.join(repo_path, IR_DIR, IR_TWIN), "w", encoding="utf-8",
4961
+ newline="\n") as fh:
4962
+ fh.write(twin)
4963
+ print("IR-RENDER %s: %s regenerated from the graph"
4964
+ % (args.repo, os.path.join(IR_DIR, IR_TWIN)))
4965
+ sys.exit(0)
4966
+
4967
+
4658
4968
  # --------------------------------------------------------------------------- #
4659
4969
  # facts (T0 / SYSTEM-FACTS: public claims become compiled artifacts of repo
4660
4970
  # facts -- Diamond applied to Diamond. ADR-012.)
@@ -8702,9 +9012,29 @@ def build_parser():
8702
9012
  pfv.add_argument("--repo", required=True)
8703
9013
  pfv.add_argument("--config", default="uscha.config.json",
8704
9014
  help="checked for defaults.fidelity.gate -- advisory there is a refusal")
9015
+ pfv.add_argument("--ir", action="store_true",
9016
+ help="answer curation_closure as a path query over the IR graph "
9017
+ "(ADR-015); reproduces v0 from the derived index")
8705
9018
  pfv.add_argument("--json", action="store_true")
8706
9019
  pfv.set_defaults(func=cmd_fidelity)
8707
9020
 
9021
+ pie = sub.add_parser(
9022
+ "ir-extract",
9023
+ help="extract the canonical package into a typed graph (ir/IR.json); what cannot be "
9024
+ "typed deterministically is UNTYPED, counted, never guessed (ADR-015)")
9025
+ pie.add_argument("--ledger", default="QA-LEDGER.json")
9026
+ pie.add_argument("--repo", required=True)
9027
+ pie.add_argument("--json", action="store_true")
9028
+ pie.set_defaults(func=cmd_ir_extract)
9029
+
9030
+ pir = sub.add_parser(
9031
+ "ir-render",
9032
+ help="regenerate the human view (ir/IR.md) from the graph; round-trip content-stable "
9033
+ "for the structured parts (ADR-015)")
9034
+ pir.add_argument("--ledger", default="QA-LEDGER.json")
9035
+ pir.add_argument("--repo", required=True)
9036
+ pir.set_defaults(func=cmd_ir_render)
9037
+
8708
9038
  pcr = sub.add_parser("cleanroom",
8709
9039
  help="run a command against a CLEAN checkout of one commit in a throwaway worktree (ADR-008)")
8710
9040
  pcr.add_argument("--ledger", default="QA-LEDGER.json")
@@ -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.71.0",
4
+ "version": "1.72.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, 40 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
6
+ "description": "Spec-driven development for LLM coding agents: 9 skills (discovery, adr-refine, reverse-discovery, characterize, devloop, sysdoc, rubric, mirador, status) + a stdlib measurement engine (qa_ledger.py, 42 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
7
7
  "author": {
8
8
  "name": "Andres Massello",
9
9
  "url": "https://github.com/andresmassello"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uscha",
3
- "version": "1.71.0",
3
+ "version": "1.72.0",
4
4
  "description": "Uscha spec-driven development methodology for coding agents. Includes npm/npx router.",
5
5
  "author": {
6
6
  "name": "Andres Massello",
@@ -1,6 +1,6 @@
1
1
  # uscha-kit
2
2
 
3
- **Kit version:** v1.71.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
3
+ **Kit version:** v1.72.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`,
package/uscha-kit/VERSION CHANGED
@@ -1 +1 @@
1
- uscha-kit 1.71.0
1
+ uscha-kit 1.72.0
@@ -0,0 +1 @@
1
+ {"AC-IR-01": true, "AC-IR-02": true, "AC-IR-03": true, "AC-IR-04": true, "AC-IR-05": true, "AC-IR-06": true}
@@ -4598,8 +4598,25 @@ def cmd_fidelity(args):
4598
4598
  % (_scope, okc, len(static_canon)))
4599
4599
  else:
4600
4600
  dims["contracts"] = _fid_dim(None, "UNMEASURED: no static-class canonical items")
4601
- # curation_closure: curated OBS / total OBS in the active delta
4602
- if obs:
4601
+ # curation_closure: curated OBS / total OBS in the active delta. With --ir (ADR-015) it is
4602
+ # answered as a path query over the graph -- OBS nodes reachable to a CURATION node via an
4603
+ # OBS->CURATION edge / OBS nodes. It reproduces v0 when the graph's OBS set equals the
4604
+ # delta's (the FIELD-RUN-001 case); a canonical carrying OBS beyond the active delta would
4605
+ # widen the denominator, which is the graph's honest answer, not v0's.
4606
+ if args.ir:
4607
+ graph = _extract_ir(repo_path, ledger)
4608
+ obs_nodes = [nd["id"] for nd in graph["nodes"] if nd["type"] == "OBS"]
4609
+ cured = {e["from"] for e in graph["edges"] if e["type"] == "OBS->CURATION"}
4610
+ if obs_nodes:
4611
+ hit = sum(1 for oid in obs_nodes if oid in cured)
4612
+ dims["curation_closure"] = _fid_dim(
4613
+ round(hit / len(obs_nodes), 4),
4614
+ "IR path query: %d/%d OBS node(s) reach a CURATION node (IR v%s)"
4615
+ % (hit, len(obs_nodes), IR_SCHEMA))
4616
+ else:
4617
+ dims["curation_closure"] = _fid_dim(
4618
+ None, "UNMEASURED: no OBS nodes in the IR graph")
4619
+ elif obs:
4603
4620
  cur = sum(1 for o in obs if o["id"] in verdicts)
4604
4621
  dims["curation_closure"] = _fid_dim(round(cur / len(obs), 4),
4605
4622
  "%d/%d OBS in %s carry a ledger verdict"
@@ -4655,6 +4672,299 @@ def cmd_fidelity(args):
4655
4672
  sys.exit(0)
4656
4673
 
4657
4674
 
4675
+ # --------------------------------------------------------------------------- #
4676
+ # IR (Diamond M2: the canonical package extracts into a typed graph. ADR-015.
4677
+ # Markdown stays canonical; the IR is a derived index. What cannot be typed
4678
+ # deterministically is UNTYPED -- visible, counted, never guessed.)
4679
+ # --------------------------------------------------------------------------- #
4680
+
4681
+ IR_DIR = "ir"
4682
+ IR_FILE = "IR.json"
4683
+ IR_TWIN = "IR.md"
4684
+ IR_SCHEMA = "0.1"
4685
+ IR_NODE_TYPES = ("REQ", "INV", "AC", "CONTRACT", "DECISION", "NFR",
4686
+ "GOLDEN", "OBS", "CURATION", "EVIDENCE")
4687
+ IR_EDGE_TYPES = ("REQ->AC", "AC->EVIDENCE", "DECISION->INV", "OBS->CURATION",
4688
+ "CURATION->canonical", "supersedes", "derived_from")
4689
+ # broader than _AC_ID: the forward package uses sub-namespaced ids (AC-DD-07, AC-FV-06)
4690
+ _IR_AC_LINE = re.compile(r"^\s*[-*]\s*\[[ xX]\]\s*(AC-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)\b\s*[-—:]*\s*(.*)$")
4691
+ _IR_INV_LINE = re.compile(r"^\s*[-*]\s*\*\*(INV-[A-Za-z0-9-]+)\s*[—-]+\s*(.*?)\*\*")
4692
+ _IR_ADR_REF = re.compile(r"\bADR-0*(\d+)\b")
4693
+ _IR_INV_REF = re.compile(r"\bINV-[A-Za-z0-9-]+\b")
4694
+ _IR_SUPERSEDE = re.compile(r"(?i)supersed\w*\s+(ADR-0*\d+)")
4695
+ _IR_BANNER = ("GENERATED by qa_ledger.py ir-extract (ADR-015). Rendered view of "
4696
+ + IR_FILE + " -- hand edits are overwritten on regeneration.")
4697
+
4698
+
4699
+ def _ir_node_id(ntype, text, source):
4700
+ """Content-address the ID-less (ADR-015 option B): nodes with a native human id keep it;
4701
+ only nodes with nothing human to anchor to get NODE-sha256(type+text+source)[:12]."""
4702
+ norm = re.sub(r"\s+", " ", (text or "").strip().lower())
4703
+ return "NODE-" + hashlib.sha256(
4704
+ (ntype + "\n" + norm + "\n" + source).encode("utf-8")).hexdigest()[:12]
4705
+
4706
+
4707
+ def _ir_read_lines(path):
4708
+ try:
4709
+ with open(path, encoding="utf-8-sig", errors="replace") as fh:
4710
+ return fh.read().splitlines()
4711
+ except OSError:
4712
+ return None
4713
+
4714
+
4715
+ def _extract_ir(repo_path, ledger):
4716
+ """Deterministic extraction of the forward canonical package into a typed graph. Every
4717
+ node/edge is derived from an EXISTING structural convention or reference -- nothing is
4718
+ inferred. A line that fills a structural slot but cannot be typed lands in `untyped`."""
4719
+ nodes, edges, untyped = [], [], []
4720
+ seen_ids = set()
4721
+
4722
+ def add(node):
4723
+ if node["id"] not in seen_ids:
4724
+ seen_ids.add(node["id"])
4725
+ nodes.append(node)
4726
+
4727
+ # AC nodes <- ACCEPTANCE.md checkboxes carrying an AC id; a checkbox WITHOUT an id is an
4728
+ # acceptance slot the conventions do not type -> untyped (never a guessed id).
4729
+ acc_lines = _ir_read_lines(os.path.join(repo_path, "ACCEPTANCE.md")) or []
4730
+ for n, ln in enumerate(acc_lines, 1):
4731
+ s = ln.strip()
4732
+ if s[:5].lower() not in ("- [x]", "- [ ]", "* [x]", "* [ ]"):
4733
+ continue
4734
+ m = _IR_AC_LINE.match(ln)
4735
+ src = {"file": "ACCEPTANCE.md", "line": n}
4736
+ if m:
4737
+ add({"id": m.group(1).upper(), "type": "AC",
4738
+ "statement": m.group(2).strip(), "source": src})
4739
+ else:
4740
+ untyped.append({"text": s[5:].strip(), "source": src,
4741
+ "reason": "acceptance checkbox without a traceable AC-id"})
4742
+
4743
+ # INV nodes <- CONSTITUTION.md `- **INV-XXX-NN — Title.**` headings
4744
+ con_lines = _ir_read_lines(os.path.join(repo_path, "CONSTITUTION.md")) or []
4745
+ inv_ids = set()
4746
+ for n, ln in enumerate(con_lines, 1):
4747
+ m = _IR_INV_LINE.match(ln)
4748
+ if m:
4749
+ add({"id": m.group(1).upper(), "type": "INV",
4750
+ "statement": m.group(2).strip().rstrip("."),
4751
+ "source": {"file": "CONSTITUTION.md", "line": n}})
4752
+ inv_ids.add(m.group(1).upper())
4753
+
4754
+ # DECISION nodes <- docs/adr/*.md (native ADR-NNN id, title, file); edges to the INVs the
4755
+ # ADR governs/states, and supersedes edges -- both from references already in the body.
4756
+ adr_dir = os.path.join(repo_path, "docs", "adr")
4757
+ ac_ids = {nd["id"] for nd in nodes if nd["type"] == "AC"}
4758
+ for row in _mirador_adrs(adr_dir):
4759
+ add({"id": row["id"], "type": "DECISION", "statement": row["t"],
4760
+ "source": {"file": os.path.relpath(row["file"], repo_path).replace(os.sep, "/"),
4761
+ "line": 1}})
4762
+ body = _ir_read_lines(row["file"]) or []
4763
+ text = "\n".join(body)
4764
+ for inv in set(_IR_INV_REF.findall(text)):
4765
+ if inv.upper() in inv_ids:
4766
+ edges.append({"from": row["id"], "to": inv.upper(), "type": "DECISION->INV"})
4767
+ for sup in set(_IR_SUPERSEDE.findall(text)):
4768
+ edges.append({"from": row["id"],
4769
+ "to": "ADR-%03d" % int(re.search(r"\d+", sup).group()),
4770
+ "type": "supersedes"})
4771
+ # an ADR's Verification checklist references its ACs -> REQ->AC is absent here (no REQ
4772
+ # layer yet), but AC nodes referenced by an ADR are real edges DECISION mentions AC:
4773
+ for aid in {m.group(0)[1:-1].upper()
4774
+ for m in re.finditer(r"\(AC-[A-Za-z0-9-]+\)", text)}:
4775
+ if aid in ac_ids: # set(): an AC cited twice is one edge
4776
+ edges.append({"from": row["id"], "to": aid, "type": "REQ->AC"})
4777
+
4778
+ # GOLDEN nodes <- git-tracked approved fixtures (the path is a native, stable id)
4779
+ tracked = _tracked_files(repo_path) or []
4780
+ gold_marker = ".appro" + "ved."
4781
+ for f in tracked:
4782
+ if gold_marker in f:
4783
+ add({"id": f, "type": "GOLDEN", "statement": "approved golden fixture",
4784
+ "source": {"file": f, "line": 1}})
4785
+
4786
+ # OBS / CURATION <- the active delta's observations (all of them, so curation_closure is
4787
+ # a real path query over the graph) + the ledger's curation records. Absent in a repo
4788
+ # that never ran a field run -> simply no such nodes.
4789
+ delta, derrs = _load_delta(repo_path)
4790
+ if delta and not derrs:
4791
+ for o in delta.get("observations") or []:
4792
+ prov = (o.get("provenance") or {}).get("files") or []
4793
+ add({"id": o["id"], "type": "OBS", "statement": o.get("statement") or "",
4794
+ "source": {"file": prov[0].split(":")[0] if prov else "delta", "line": 1}})
4795
+ canon_path = os.path.join(repo_path, CANDIDATE_DIR, CANONICAL_FILE)
4796
+ canon_items = []
4797
+ if os.path.isfile(canon_path):
4798
+ try:
4799
+ with open(canon_path, encoding="utf-8-sig") as fh:
4800
+ canon_items = json.load(fh).get("items") or []
4801
+ except (OSError, ValueError):
4802
+ canon_items = []
4803
+ for it in canon_items:
4804
+ oid = it.get("derived_from")
4805
+ if not oid:
4806
+ continue
4807
+ prov = (it.get("provenance") or {}).get("files") or []
4808
+ add({"id": oid, "type": "OBS", "statement": it.get("statement") or "",
4809
+ "source": {"file": prov[0].split(":")[0] if prov else "canonical", "line": 1}})
4810
+ for rec in ledger.get("curation") or []:
4811
+ oid = rec.get("obs_id")
4812
+ if not oid:
4813
+ continue
4814
+ cid = "CUR-" + hashlib.sha256(
4815
+ (oid + "\n" + (rec.get("at") or "")).encode("utf-8")).hexdigest()[:12]
4816
+ add({"id": cid, "type": "CURATION",
4817
+ "statement": "%s: %s" % (rec.get("verdict"), oid),
4818
+ "source": {"file": "QA-LEDGER.json", "line": 0}})
4819
+ if oid in seen_ids:
4820
+ edges.append({"from": oid, "to": cid, "type": "OBS->CURATION"})
4821
+
4822
+ node_ids = {nd["id"] for nd in nodes}
4823
+ kept, dropped = [], 0
4824
+ for e in edges:
4825
+ if e["from"] in node_ids and e["to"] in node_ids:
4826
+ kept.append(e)
4827
+ elif e["type"] == "supersedes" and e["from"] in node_ids:
4828
+ kept.append(e) # a superseded ADR may be archived; keep it
4829
+ else:
4830
+ dropped += 1
4831
+ nodes.sort(key=lambda nd: (nd["type"], nd["id"]))
4832
+ kept.sort(key=lambda e: (e["type"], e["from"], e["to"]))
4833
+ untyped.sort(key=lambda u: (u["source"]["file"], u["source"]["line"]))
4834
+ counts = {t: sum(1 for nd in nodes if nd["type"] == t) for t in IR_NODE_TYPES}
4835
+ stats = {"nodes": len(nodes), "edges": len(kept),
4836
+ "edges_dropped": dropped, "untyped": len(untyped),
4837
+ "by_type": {t: c for t, c in counts.items() if c},
4838
+ "untyped_rate": round(len(untyped) / (len(nodes) + len(untyped)), 4)
4839
+ if (nodes or untyped) else 0.0}
4840
+ graph = {"schema_version": IR_SCHEMA,
4841
+ "_generated_by": "qa_ledger.py ir-extract (ADR-015) -- derived index of the "
4842
+ "canonical package; never hand-edit",
4843
+ "nodes": nodes, "edges": kept, "untyped": untyped, "stats": stats}
4844
+ # the seal covers stats too (fresh-review MEDIUM): a doctored summary that ir-render
4845
+ # would faithfully print must trip the strict loader, not slip past it -- the
4846
+ # derived-but-unsealed lesson, applied a third time (path @1.70, evidence_class @1.69).
4847
+ graph["_integrity"] = _ir_seal(graph)
4848
+ return graph
4849
+
4850
+
4851
+ def _ir_seal(graph):
4852
+ return _integrity_hash({k: graph.get(k) for k in
4853
+ ("schema_version", "nodes", "edges", "untyped", "stats")})
4854
+
4855
+
4856
+ def _load_ir(repo_path):
4857
+ """Strict loader: an unknown schema_version or a broken graph is exit-2 class, never
4858
+ mis-read (AC-IR-05). Returns (graph, errors); graph None when absent."""
4859
+ path = os.path.join(repo_path, IR_DIR, IR_FILE)
4860
+ if not os.path.isfile(path):
4861
+ return None, []
4862
+ try:
4863
+ with open(path, encoding="utf-8-sig") as fh:
4864
+ g = json.load(fh)
4865
+ except (OSError, ValueError) as exc:
4866
+ return {}, ["unreadable: %s" % exc]
4867
+ errors = []
4868
+ if g.get("schema_version") != IR_SCHEMA:
4869
+ errors.append("schema_version %r != %r (a version this engine does not know is not "
4870
+ "read, it is refused)" % (g.get("schema_version"), IR_SCHEMA))
4871
+ return g, errors
4872
+ if not isinstance(g.get("nodes"), list) or not isinstance(g.get("edges"), list):
4873
+ return g, ["nodes/edges missing or not lists"]
4874
+ if g.get("_integrity") != _ir_seal(g):
4875
+ errors.append("integrity seal does not match the graph -- nodes, edges, untyped or "
4876
+ "stats was hand-edited (regenerate via ir-extract)")
4877
+ return g, errors
4878
+
4879
+
4880
+ def _render_ir_md(graph):
4881
+ st = graph.get("stats") or {}
4882
+ lines = ["<!-- %s -->" % _IR_BANNER, "", "# Uscha IR v%s (rendered view)" % IR_SCHEMA, "",
4883
+ "%d nodes · %d edges · %d UNTYPED (rate %.2f)"
4884
+ % (st.get("nodes", 0), st.get("edges", 0), st.get("untyped", 0),
4885
+ st.get("untyped_rate", 0.0)), "",
4886
+ "## Nodes", "", "| id | type | statement | source |",
4887
+ "|----|------|-----------|--------|"]
4888
+ for nd in graph.get("nodes") or []:
4889
+ src = "%s:%s" % (nd["source"]["file"], nd["source"]["line"])
4890
+ stmt = (nd.get("statement") or "").replace("|", "\\|")
4891
+ lines.append("| %s | %s | %s | %s |" % (nd["id"], nd["type"], stmt, src))
4892
+ lines += ["", "## Edges", "", "| from | type | to |", "|------|------|----|"]
4893
+ for e in graph.get("edges") or []:
4894
+ lines.append("| %s | %s | %s |" % (e["from"], e["type"], e["to"]))
4895
+ if graph.get("untyped"):
4896
+ lines += ["", "## UNTYPED (conventions the human layer is missing)", "",
4897
+ "| text | source | reason |", "|------|--------|--------|"]
4898
+ for u in graph["untyped"]:
4899
+ src = "%s:%s" % (u["source"]["file"], u["source"]["line"])
4900
+ txt = (u.get("text") or "").replace("|", "\\|")[:80]
4901
+ lines.append("| %s | %s | %s |" % (txt, src, u.get("reason", "")))
4902
+ lines.append("")
4903
+ return "\n".join(lines)
4904
+
4905
+
4906
+ def cmd_ir_extract(args):
4907
+ ledger = _load(args.ledger)
4908
+ _repo_node(ledger, args.repo)
4909
+ repo_path = _scope_path(ledger, args.repo)
4910
+ graph = _extract_ir(repo_path, ledger)
4911
+ ir_dir = os.path.join(repo_path, IR_DIR)
4912
+ os.makedirs(ir_dir, exist_ok=True)
4913
+ with open(os.path.join(ir_dir, IR_FILE), "w", encoding="utf-8", newline="\n") as fh:
4914
+ fh.write(json.dumps(graph, indent=2, ensure_ascii=False) + "\n")
4915
+ twin_path = os.path.join(ir_dir, IR_TWIN)
4916
+ twin = _render_ir_md(graph)
4917
+ prev = None
4918
+ if os.path.isfile(twin_path):
4919
+ try:
4920
+ with open(twin_path, encoding="utf-8-sig") as fh:
4921
+ prev = fh.read()
4922
+ except OSError:
4923
+ prev = None
4924
+ with open(twin_path, "w", encoding="utf-8", newline="\n") as fh:
4925
+ fh.write(twin)
4926
+ if prev is not None and prev != twin:
4927
+ print("[qa_ledger] ir-extract: %s regenerated (rendered view, never a source)"
4928
+ % IR_TWIN, file=sys.stderr)
4929
+ st = graph["stats"]
4930
+ if args.json:
4931
+ print(json.dumps(st, indent=2, ensure_ascii=False))
4932
+ else:
4933
+ print("IR-EXTRACT %s: %d nodes, %d edges -> %s"
4934
+ % (args.repo, st["nodes"], st["edges"],
4935
+ os.path.join(IR_DIR, IR_FILE)))
4936
+ print(" by type: " + ", ".join("%s=%d" % (t, c)
4937
+ for t, c in st["by_type"].items()))
4938
+ print(" UNTYPED: %d (rate %.2f) -- the size of what the conventions cannot yet type"
4939
+ % (st["untyped"], st["untyped_rate"]))
4940
+ if st["edges_dropped"]:
4941
+ print(" %d edge(s) dropped (endpoint not a node) -- counted, never dangling"
4942
+ % st["edges_dropped"])
4943
+ sys.exit(0)
4944
+
4945
+
4946
+ def cmd_ir_render(args):
4947
+ ledger = _load(args.ledger)
4948
+ _repo_node(ledger, args.repo)
4949
+ repo_path = _scope_path(ledger, args.repo)
4950
+ graph, errors = _load_ir(repo_path)
4951
+ if graph is None:
4952
+ print("[qa_ledger] ir-render: no %s -- run ir-extract first."
4953
+ % os.path.join(IR_DIR, IR_FILE), file=sys.stderr)
4954
+ sys.exit(2)
4955
+ if errors:
4956
+ for e in errors:
4957
+ print("[qa_ledger] ir-render: %s" % e, file=sys.stderr)
4958
+ sys.exit(2)
4959
+ twin = _render_ir_md(graph)
4960
+ with open(os.path.join(repo_path, IR_DIR, IR_TWIN), "w", encoding="utf-8",
4961
+ newline="\n") as fh:
4962
+ fh.write(twin)
4963
+ print("IR-RENDER %s: %s regenerated from the graph"
4964
+ % (args.repo, os.path.join(IR_DIR, IR_TWIN)))
4965
+ sys.exit(0)
4966
+
4967
+
4658
4968
  # --------------------------------------------------------------------------- #
4659
4969
  # facts (T0 / SYSTEM-FACTS: public claims become compiled artifacts of repo
4660
4970
  # facts -- Diamond applied to Diamond. ADR-012.)
@@ -8702,9 +9012,29 @@ def build_parser():
8702
9012
  pfv.add_argument("--repo", required=True)
8703
9013
  pfv.add_argument("--config", default="uscha.config.json",
8704
9014
  help="checked for defaults.fidelity.gate -- advisory there is a refusal")
9015
+ pfv.add_argument("--ir", action="store_true",
9016
+ help="answer curation_closure as a path query over the IR graph "
9017
+ "(ADR-015); reproduces v0 from the derived index")
8705
9018
  pfv.add_argument("--json", action="store_true")
8706
9019
  pfv.set_defaults(func=cmd_fidelity)
8707
9020
 
9021
+ pie = sub.add_parser(
9022
+ "ir-extract",
9023
+ help="extract the canonical package into a typed graph (ir/IR.json); what cannot be "
9024
+ "typed deterministically is UNTYPED, counted, never guessed (ADR-015)")
9025
+ pie.add_argument("--ledger", default="QA-LEDGER.json")
9026
+ pie.add_argument("--repo", required=True)
9027
+ pie.add_argument("--json", action="store_true")
9028
+ pie.set_defaults(func=cmd_ir_extract)
9029
+
9030
+ pir = sub.add_parser(
9031
+ "ir-render",
9032
+ help="regenerate the human view (ir/IR.md) from the graph; round-trip content-stable "
9033
+ "for the structured parts (ADR-015)")
9034
+ pir.add_argument("--ledger", default="QA-LEDGER.json")
9035
+ pir.add_argument("--repo", required=True)
9036
+ pir.set_defaults(func=cmd_ir_render)
9037
+
8708
9038
  pcr = sub.add_parser("cleanroom",
8709
9039
  help="run a command against a CLEAN checkout of one commit in a throwaway worktree (ADR-008)")
8710
9040
  pcr.add_argument("--ledger", default="QA-LEDGER.json")
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.71.0",
2
+ "version": "1.72.0",
3
3
  "project": null,
4
4
  "defaults": {
5
5
  "coverage_threshold": 60,