@andresmassello/uscha 1.86.1 → 1.88.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.86.1** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
43
+ **Kit v1.88.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
 
@@ -85,7 +85,7 @@ automatic tool can perform: a human verdict.
85
85
  from the compiled code: 0.062 measured (12 archetypes) — names, not yet semantics
86
86
  ```
87
87
 
88
- **What each arrow is, in the engine (kit 1.86.1, 52 subcommands, all measured):**
88
+ **What each arrow is, in the engine (kit 1.88.0, 52 subcommands, all measured):**
89
89
 
90
90
  | Leg | Subcommands | What it establishes |
91
91
  |---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andresmassello/uscha",
3
- "version": "1.86.1",
3
+ "version": "1.88.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",
@@ -535,7 +535,10 @@ once logged it caps readiness ≤65 and blocks convergence until resolved with
535
535
  criterion carries a stable ID: `- [ ] AC-01 — when X then Y`. A criterion counts as
536
536
  CLOSED only when ≥1 GREEN testcase whose name carries the tag (`test_ac1_x`,
537
537
  `testAC01X`, `"AC-01: ..."` — IDs normalize by number, `AC-01 == AC_1 == ac1`) exists
538
- in the ingested JUnit reports AND no tagged testcase is red. The checkbox is the
538
+ in the ingested JUnit reports AND no tagged testcase is red. Since kit 1.87.0 (ADR-036)
539
+ a FAMILY prefix is read the same way: `- [ ] AC-BC-07 — ...` closes on `AC-BC-07_x`,
540
+ `test_ac_bc_7_y` or `AC_BC_7` (normalized to `AC-BC-7`; the family needs a separator on
541
+ both sides — camelCase `testACBC07` is NOT a tag, and `AC-7-x` is still the bare `AC-7`). The checkbox is the
539
542
  NARRATIVE; the testcase is the FACT — a checked box without a green tagged test shows
540
543
  up as `narrated_only` and does NOT close (measured beats narrated, per criterion).
541
544
  A JUnit report older than the repo's source code is treated as STALE (the code changed
@@ -764,11 +764,43 @@ def _test_evidence_provenance(repo_path, repo_type):
764
764
  _AC_TAG = re.compile(
765
765
  r"(?:(?<![A-Za-z0-9])[Aa][Cc]|(?<=[a-z])AC)[-_]?0*(\d+)(?!\d)")
766
766
 
767
+ # kit 1.87.0 (ADR-036): the FAMILY grammar 'AC-<FAMILY>-<n>' (AC-BC-07, AC-T-24,
768
+ # ac_dd_3). Same explicit boundaries as the bare form, plus a MANDATORY separator
769
+ # on BOTH sides of the family — 'AC-BC-07', 'ac_bc_7', 'AC_T_1'. camelCase families
770
+ # are deliberately NOT supported: 'testACBC07' has no honest split into family +
771
+ # number and stays unmatched.
772
+ # Kept as its OWN pattern so the bare pattern above (and every tag it already
773
+ # produced) stays byte-identical. The two are disjoint by construction: a family
774
+ # must start with a LETTER, so 'AC-01' can never match this one, and the digits of
775
+ # 'AC-BC-07' do not follow 'AC', so it can never match the bare one.
776
+ _AC_TAG_FAM = re.compile(
777
+ r"(?:(?<![A-Za-z0-9])[Aa][Cc]|(?<=[a-z])AC)"
778
+ r"[-_]([A-Za-z][A-Za-z0-9]*)[-_]0*(\d+)(?!\d)")
779
+
780
+
781
+ def _ac_canon(family, num):
782
+ """Canonical criterion id for both grammars (ADR-036): 'AC-<int>' for the bare
783
+ form (AC-01 == AC_1 == ac1) and 'AC-<FAMILY>-<int>' for the family form
784
+ (AC-T-01 == AC-T-1 == ac_t_1) — python/go test names cannot carry '-', so the
785
+ separator and the zero padding are never part of the identity."""
786
+ if family:
787
+ return "AC-%s-%d" % (family.upper(), int(num))
788
+ return "AC-%d" % int(num)
789
+
790
+
791
+ def _ac_tag_ids(name):
792
+ """Every criterion id a testcase NAME tags, normalized. Family form first, then
793
+ bare; the two patterns are disjoint, so the order fixes only the output order."""
794
+ ids = [_ac_canon(fam, num) for fam, num in _AC_TAG_FAM.findall(name)]
795
+ ids.extend(_ac_canon(None, num) for num in _AC_TAG.findall(name))
796
+ return ids
797
+
767
798
 
768
799
  def _ac_tags(repo_path, repo_type):
769
800
  """Tags AC-n leidos de los NOMBRES de testcase en los reportes JUnit que el
770
801
  engine ya ingiere. Devuelve (tags, stale) donde tags = {'AC-n': {'green': x,
771
- 'red': y}} y stale = [rutas de reportes descartados por viejos]. Un criterio
802
+ 'red': y}} (o 'AC-FAM-n' para la forma con familia, ADR-036 / kit 1.87.0)
803
+ y stale = [rutas de reportes descartados por viejos]. Un criterio
772
804
  cierra MEDIDO solo con >=1 testcase verde y 0 rojos (evidencia roja veta:
773
805
  fail-closed). Testcases skipped no cuentan para ningun lado.
774
806
 
@@ -812,9 +844,8 @@ def _ac_tags(repo_path, repo_type):
812
844
  # cuyo nombre matchea 'ACn' por coincidencia (test_ac3_flow.py) no
813
845
  # debe taggear los OTROS tests del mismo archivo/clase.
814
846
  blob = tc.get("name") or ""
815
- for num in _AC_TAG.findall(blob):
816
- d = tags.setdefault(f"AC-{int(num)}",
817
- {"green": 0, "red": 0, "cases": []})
847
+ for cid in _ac_tag_ids(blob):
848
+ d = tags.setdefault(cid, {"green": 0, "red": 0, "cases": []})
818
849
  d[status] += 1
819
850
  # RECEIPT (kit 1.50.0): keep WHICH testcase in WHICH report backed the
820
851
  # verdict -- the name and path were always in scope here and were being
@@ -4213,15 +4244,20 @@ def _canonical_ids(repo_path, acceptance_file):
4213
4244
  except Exception:
4214
4245
  return {}
4215
4246
  for it in items or []:
4216
- if it.get("id"): # normalized "AC-<n>" (numeric ids only)
4217
- ids[int(it["id"].split("-")[1])] = it["id"]
4247
+ if it.get("id"):
4248
+ # normalized id of EITHER grammar (ADR-036): "AC-<n>" or "AC-<FAMILY>-<n>". Keyed by
4249
+ # the canonical id itself; a bare and a family id can never collide.
4250
+ ids[it["id"]] = it["id"]
4218
4251
  return ids
4219
4252
 
4220
4253
 
4221
4254
  def _match_canonical(statement, canon_ids):
4222
- m = re.search(r"(?i)\bAC[-_]?0*(\d+)\b", statement)
4223
- if m and int(m.group(1)) in canon_ids:
4224
- return canon_ids[int(m.group(1))]
4255
+ """The canonical id an observation's statement names, if any -- read with the same
4256
+ grammar as ACCEPTANCE.md and the JUnit tags (ADR-036), so a statement that mentions
4257
+ 'AC-DD-07' anchors the criterion AC-DD-7 exactly as one that mentions 'AC-7' anchors AC-7."""
4258
+ for cid in _ac_tag_ids(statement or ""):
4259
+ if cid in canon_ids:
4260
+ return canon_ids[cid]
4225
4261
  return None
4226
4262
 
4227
4263
 
@@ -7175,12 +7211,34 @@ def _band(score):
7175
7211
 
7176
7212
  _AC_ID = re.compile(r"(?i)^[*_`]*\s*AC[-_]?0*(\d+)\b[*_`]*[\s.:—–·-]*")
7177
7213
 
7214
+ # kit 1.87.0 (ADR-036): the family grammar, same tolerated wrappers/separators.
7215
+ # A family starts with a LETTER, so a numeric "family" is not one: '- [ ] AC-7-x'
7216
+ # falls through to the bare pattern and reads as AC-7 followed by the text 'x' (the
7217
+ # trailing-separator class eats the hyphen, exactly as before ADR-036).
7218
+ _AC_ID_FAM = re.compile(
7219
+ r"(?i)^[*_`]*\s*AC[-_]([A-Za-z][A-Za-z0-9]*)[-_]0*(\d+)\b[*_`]*[\s.:—–·-]*")
7220
+
7221
+
7222
+ def _ac_id_of(body):
7223
+ """(canonical id, end offset) of the leading AC id of a checkbox body, or
7224
+ (None, 0). The FAMILY form is tried first; the bare form is the fallback, so
7225
+ every id the engine read before ADR-036 still reads exactly the same."""
7226
+ m = _AC_ID_FAM.match(body)
7227
+ if m:
7228
+ return _ac_canon(m.group(1), m.group(2)), m.end()
7229
+ m = _AC_ID.match(body)
7230
+ if m:
7231
+ return _ac_canon(None, m.group(1)), m.end()
7232
+ return None, 0
7233
+
7178
7234
 
7179
7235
  def _parse_acceptance_items(path, section=None):
7180
7236
  """Checkboxes markdown de ACCEPTANCE, con ID trazable opcional por criterio
7181
7237
  ('- [ ] AC-01 — cuando X entonces Y'). Los IDs se normalizan por numero
7182
- (AC-01 == AC_1 == ac1 — los nombres de test de python/go no admiten '-').
7183
- Devuelve (items, found); item = {'id': 'AC-n'|None, 'checked', 'text'}."""
7238
+ (AC-01 == AC_1 == ac1 — los nombres de test de python/go no admiten '-') y,
7239
+ desde kit 1.87.0 (ADR-036), tambien por FAMILIA ('- [ ] AC-BC-07 — ...',
7240
+ AC-T-01 == AC-T-1 == ac_t_1). Devuelve (items, found);
7241
+ item = {'id': 'AC-n'|'AC-FAM-n'|None, 'checked', 'text'}."""
7184
7242
  if not path or not os.path.exists(path):
7185
7243
  return [], False
7186
7244
  items = []
@@ -7203,10 +7261,10 @@ def _parse_acceptance_items(path, section=None):
7203
7261
  else:
7204
7262
  continue
7205
7263
  body = s[5:].strip()
7206
- m = _AC_ID.match(body)
7207
- items.append({"id": f"AC-{int(m.group(1))}" if m else None,
7264
+ cid, end = _ac_id_of(body)
7265
+ items.append({"id": cid,
7208
7266
  "checked": checked,
7209
- "text": body[m.end():].strip() if m else body})
7267
+ "text": body[end:].strip()})
7210
7268
  except OSError:
7211
7269
  return [], False
7212
7270
  return items, True
@@ -8157,12 +8215,183 @@ def _top_pct(done, total):
8157
8215
  return 99 if (done < total and pct >= 100) else pct
8158
8216
 
8159
8217
 
8160
- def _top_ac_num(cid):
8161
- """Stable numeric order for the normalized 'AC-<n>' ids _parse_acceptance_items emits."""
8218
+ def _top_ac_key(cid):
8219
+ """Stable order for the normalized ids _parse_acceptance_items emits: the BARE
8220
+ 'AC-<n>' criteria first by number, then each letter FAMILY alphabetically and by
8221
+ number inside it (ADR-036). An id in neither shape sorts last instead of raising —
8222
+ the board must still render when the acceptance file carries something unexpected."""
8223
+ parts = str(cid).split("-")
8224
+ if len(parts) >= 3:
8225
+ try:
8226
+ return (1, parts[1].upper(), int(parts[2]))
8227
+ except ValueError:
8228
+ return (2, str(cid), 0)
8162
8229
  try:
8163
- return int(str(cid).split("-")[1])
8230
+ return (0, "", int(parts[1]))
8164
8231
  except (IndexError, ValueError):
8165
- return 0
8232
+ return (2, str(cid), 0)
8233
+
8234
+
8235
+ TOP_EVENTS_TAIL = 8 # how many steps the feed carries; the TUI shows what fits
8236
+ TOP_EVENT_WIDTH = 72 # one feed line, short enough to survive the 80-column floor
8237
+
8238
+ # kind -> level, the FIXED map ADR-032 (amended 1.88.0, M2) requires. `level` and `text` do
8239
+ # not exist in `ledger["steps"]`; they are derived here, once, so the TUI renders a feed it
8240
+ # did not author (ADR-034). A kind absent from this map reads `info`: an unclassified step
8241
+ # is never a green one. Four kinds get their level REFINED below from the record the step
8242
+ # announces (the iteration/escalation with the same `n`, the k-th clean-room record of the
8243
+ # repo) -- and when that correlation misses, the level stays at its neutral value instead of
8244
+ # guessing a verdict.
8245
+ TOP_EVENT_LEVELS = {
8246
+ "snapshot": "info", # -> fail when the snapshot recorded red tests
8247
+ "qa-step": "info", # -> pass when nothing was reported, or all fixed
8248
+ "static-gate": "info", # -> pass/fail by the gated finding count
8249
+ "cleanroom": "info", # -> pass/fail by the record's `ok`
8250
+ "fastpath-eval": "info",
8251
+ "gate-not-run": "unmeasured", # a gate nobody ran is UNMEASURED, not a pass
8252
+ "escalation": "human",
8253
+ "escalation-resolved": "human",
8254
+ "production-finding": "human",
8255
+ "production-finding:resolve": "human",
8256
+ "spec-doubt": "human",
8257
+ "spec-doubt:resolve": "human",
8258
+ "spec-change-request": "human",
8259
+ "spec-change-request:resolve": "human",
8260
+ }
8261
+
8262
+
8263
+ def _top_ts(iso):
8264
+ """HH:MM:SS in UTC, or None. A stamp carrying an offset is normalized to UTC (machine-
8265
+ independent); it is never converted to the LOCAL zone, which would make the same ledger
8266
+ read differently on two boxes and break the golden frames."""
8267
+ dt = _top_dt(iso)
8268
+ if dt is None:
8269
+ return None
8270
+ if dt.tzinfo is not None:
8271
+ dt = dt.astimezone(timezone.utc)
8272
+ return dt.strftime("%H:%M:%S")
8273
+
8274
+
8275
+ def _top_key(value):
8276
+ """A dict key that cannot raise. The writers always put a scalar in `n`, `at` and `repo`,
8277
+ but the ledger is JSON on disk and a hand edit can leave a list or a dict there --
8278
+ `unhashable type` is not how a read-only readout gets to report that."""
8279
+ if isinstance(value, (str, int, float, bool)) or value is None:
8280
+ return value
8281
+ return repr(value)
8282
+
8283
+
8284
+ def _top_event_text(*parts):
8285
+ """One feed line: the only free text of the whole contract that reaches a terminal.
8286
+
8287
+ Its ingredients are ledger prose (an escalation reason, a tool name) -- human and CLI
8288
+ input -- so an ESC or a C0 byte inside one would be a control sequence the board prints
8289
+ verbatim. Every control character is dropped HERE, in the engine, and the renderer drops
8290
+ them again on the way out: two cheap guards over one attack surface."""
8291
+ txt = " · ".join(str(p) for p in parts if p not in (None, "", "?"))
8292
+ txt = "".join(" " if c in ("\t", "\n", "\r") else c for c in txt)
8293
+ txt = "".join(c for c in txt if ord(c) >= 32 and ord(c) != 127)
8294
+ txt = " ".join(txt.split())
8295
+ if len(txt) > TOP_EVENT_WIDTH:
8296
+ txt = txt[:TOP_EVENT_WIDTH - 1] + "…"
8297
+ return txt
8298
+
8299
+
8300
+ def _top_events(ledger, limit=TOP_EVENTS_TAIL):
8301
+ """events_tail[]: the last `limit` steps as {ts, level, text}, NEWEST FIRST.
8302
+
8303
+ Deterministic given the ledger and read-only, like the rest of `cmd_top`. The step
8304
+ records carry `n, at, kind, repo` plus a few per-kind fields; everything else the feed
8305
+ shows comes from the record that step announces, correlated the way the ledger really
8306
+ supports it: by `n` for iterations, escalations and fast-path entries (the writer copies
8307
+ the counter into both), in ORDER for clean-room records (step and record are appended in
8308
+ the same call), and by `(repo, at)` for snapshots. A miss degrades that one line to
8309
+ `info` -- under-claiming a verdict, never inventing one."""
8310
+ nodes = dict(ledger.get("repos") or {})
8311
+ nodes["integration"] = ledger.get("integration") or {}
8312
+ iters, snaps = {}, {}
8313
+ for rname, node in nodes.items():
8314
+ for it in (node or {}).get("iterations") or []:
8315
+ if isinstance(it, dict) and it.get("n") is not None:
8316
+ iters[(rname, _top_key(it.get("n")))] = it
8317
+ for sn in (node or {}).get("snapshots") or []:
8318
+ if isinstance(sn, dict):
8319
+ snaps.setdefault((rname, _top_key(sn.get("at"))), sn)
8320
+ esc = {_top_key(e.get("n")): e for e in ledger.get("escalations") or []
8321
+ if isinstance(e, dict) and e.get("n") is not None}
8322
+ fastp = {_top_key(e.get("n")): e for e in ledger.get("fast_path") or []
8323
+ if isinstance(e, dict) and e.get("n") is not None}
8324
+ crs = {}
8325
+ for rec in ledger.get(CLEAN_ROOM_KEY) or []:
8326
+ if isinstance(rec, dict):
8327
+ crs.setdefault(str(rec.get("repo") or ""), []).append(rec)
8328
+ cr_seen = {}
8329
+
8330
+ events = []
8331
+ for st in ledger.get("steps") or []:
8332
+ if not isinstance(st, dict):
8333
+ continue
8334
+ kind = str(st.get("kind") or "")
8335
+ level = TOP_EVENT_LEVELS.get(kind, "info")
8336
+ try:
8337
+ repo = str(st.get("repo") or "")
8338
+ n = _top_key(st.get("n"))
8339
+ head, tail = kind or "step", None
8340
+
8341
+ if kind == "snapshot":
8342
+ head = "snapshot " + repo if repo else "snapshot"
8343
+ tail = "phase %s" % st.get("phase") if st.get("phase") else None
8344
+ tests = (snaps.get((repo, _top_key(st.get("at")))) or {}).get("tests") or {}
8345
+ red = (tests.get("failures") or 0) + (tests.get("errors") or 0)
8346
+ if tests.get("report_found") and red:
8347
+ level, tail = "fail", "%d red test(s)" % red
8348
+ elif kind in ("qa-step", "static-gate", "gate-not-run"):
8349
+ head = "%s %s" % (kind, "/".join(str(p) for p in (repo, st.get("tool")) if p))
8350
+ it = iters.get((repo, n)) or {}
8351
+ rep, fixed = it.get("reported"), it.get("fixed")
8352
+ gated = it.get("gated_reported")
8353
+ if kind == "gate-not-run":
8354
+ tail = "not run — nobody measured it"
8355
+ elif kind == "static-gate" and isinstance(gated, int):
8356
+ level = "fail" if gated >= 1 else "pass"
8357
+ tail = "%d gated finding(s)" % gated if gated else "clean"
8358
+ elif kind == "qa-step" and isinstance(rep, int):
8359
+ if rep == 0 or (isinstance(fixed, int) and fixed >= rep):
8360
+ level = "pass"
8361
+ tail = "%d reported, %s fixed" % (rep,
8362
+ fixed if fixed is not None else "?")
8363
+ elif kind == "cleanroom":
8364
+ head = "cleanroom " + repo if repo else "cleanroom"
8365
+ queue = crs.get(repo) or []
8366
+ idx = cr_seen.get(repo, 0)
8367
+ cr_seen[repo] = idx + 1
8368
+ rec = queue[idx] if idx < len(queue) else {}
8369
+ if rec.get("status") and rec.get("ok") is not None:
8370
+ level = "pass" if rec.get("ok") else "fail"
8371
+ tail = str(rec.get("status"))
8372
+ elif kind == "fastpath-eval":
8373
+ head = "fastpath-eval " + repo if repo else "fastpath-eval"
8374
+ tail = (fastp.get(n) or {}).get("verdict")
8375
+ elif kind in ("escalation", "escalation-resolved"):
8376
+ head = "%s %s" % (kind, repo) if repo else kind
8377
+ # `escalation-resolved` gets a FRESH counter of its own, so there is no
8378
+ # record to look up: it says what happened and nothing more.
8379
+ tail = (esc.get(n) or {}).get("reason") if kind == "escalation" else None
8380
+ else:
8381
+ head = "%s %s" % (kind, repo) if repo else (kind or "step")
8382
+ tail = st.get("id")
8383
+ text = _top_event_text(head, tail)
8384
+ except Exception:
8385
+ # a ledger is JSON on disk: any field can arrive as a list, a dict or a number
8386
+ # from a hand edit. ONE unreadable step degrades to a neutral line naming its
8387
+ # kind -- the readout never raises and never loses the JSON the board needs
8388
+ # (the same fail-soft rule _top_dt already applies to timestamps).
8389
+ level, text = "info", _top_event_text(kind or "step")
8390
+
8391
+ events.append({"ts": _top_ts(st.get("at")), "level": level, "text": text})
8392
+
8393
+ events.reverse() # newest first, as the board reads top-down
8394
+ return events[:max(0, int(limit))]
8166
8395
 
8167
8396
 
8168
8397
  def cmd_top(args):
@@ -8234,7 +8463,7 @@ def cmd_top(args):
8234
8463
  seen.add(it["id"])
8235
8464
  ids.append(it["id"])
8236
8465
  obligations = []
8237
- for cid in sorted(ids, key=_top_ac_num):
8466
+ for cid in sorted(ids, key=_top_ac_key):
8238
8467
  tag, obs = ac_tags.get(cid), quarantine.get(cid)
8239
8468
  # red evidence VETOES (fail-closed, the same rule _ac_closed applies). Measured
8240
8469
  # evidence outranks the lateral QUARANTINE rung so the four buckets partition the
@@ -8281,7 +8510,9 @@ def cmd_top(args):
8281
8510
  "generated_at": _now(),
8282
8511
  "obligations": obligations,
8283
8512
  "observations": observations,
8284
- "events_tail": [], # the live feed is M2; the key ships empty, not absent
8513
+ # the live feed (M2): the last steps, newest first, with `level`/`text` derived by the
8514
+ # fixed per-kind map above -- in the engine, so the TUI authors no verdict of its own.
8515
+ "events_tail": _top_events(ledger),
8285
8516
  "counts": {"measured_pass": done, "measured_fail": fail, "quarantine": quar,
8286
8517
  "unmeasured": _n("UNMEASURED"), "traced": 0, "tagged": 0, "total": total},
8287
8518
  "terminado": {"done": done, "total": total, "pct": pct, "unmeasured": unmeasured},
@@ -8346,13 +8577,13 @@ def cmd_readiness(args):
8346
8577
  # IDs duplicados (ACCEPTANCE mal numerado) cuentan UNA sola vez — si no,
8347
8578
  # un solo test verde cierra "medido" tantos criterios como copias del ID.
8348
8579
  id_list = [i["id"] for i in ac_ids]
8349
- dupe_ids = sorted({cid for cid in id_list if id_list.count(cid) > 1})
8350
- unique_ids = sorted(set(id_list))
8580
+ dupe_ids = sorted({cid for cid in id_list if id_list.count(cid) > 1}, key=_top_ac_key)
8581
+ unique_ids = sorted(set(id_list), key=_top_ac_key)
8351
8582
  measured_closed = [cid for cid in unique_ids if _ac_closed(cid)]
8352
8583
  narrated_only = sorted({i["id"] for i in ac_ids
8353
- if i["checked"] and not _ac_closed(i["id"])})
8584
+ if i["checked"] and not _ac_closed(i["id"])}, key=_top_ac_key)
8354
8585
  measured_unchecked = sorted({i["id"] for i in ac_ids
8355
- if not i["checked"] and _ac_closed(i["id"])})
8586
+ if not i["checked"] and _ac_closed(i["id"])}, key=_top_ac_key)
8356
8587
  ac_untagged = total - len(ac_ids)
8357
8588
  acc_traceable = bool(ac_ids)
8358
8589
  if acc_traceable:
@@ -8627,8 +8858,9 @@ def cmd_readiness(args):
8627
8858
  f"(--section {args.section!r} matched nothing in the file?) — "
8628
8859
  f"adr/acceptance dimensions at 0")
8629
8860
  if acc_found and total and not acc_traceable:
8630
- print(" ! acceptance has no traceable IDs ('- [ ] AC-01 — ...') the "
8631
- "acceptance dimension falls back to the checkbox ratio (NARRATED, not measured)")
8861
+ print(" ! acceptance has no traceable IDs ('- [ ] AC-01 — ...' or "
8862
+ "'- [ ] AC-BC-01 — ...') — the acceptance dimension falls back to the "
8863
+ "checkbox ratio (NARRATED, not measured)")
8632
8864
  if dupe_ids:
8633
8865
  print(f" ! duplicate IDs in acceptance (normalized): {', '.join(dupe_ids)} "
8634
8866
  f"— each ID counts ONCE in the acceptance dimension")
@@ -10102,8 +10334,9 @@ def _spec_check_text(text):
10102
10334
  def _acceptance_traceability(path):
10103
10335
  """Trazabilidad del ACCEPTANCE (kit 1.10.0): estructura = FACT.
10104
10336
  Bloquea: archivo ausente, cero criterios, CERO criterios con AC-ID, IDs
10105
- duplicados (tras normalizar: AC-01 == AC-1). Aconseja: criterios sueltos
10106
- sin ID (no podran cerrar MEDIDO)."""
10337
+ duplicados (tras normalizar: AC-01 == AC-1, y desde kit 1.87.0 / ADR-036
10338
+ AC-BC-07 == AC-BC-7). Aconseja: criterios sueltos sin ID (no podran cerrar
10339
+ MEDIDO)."""
10107
10340
  blockers, advisory = [], []
10108
10341
  items, found = _parse_acceptance_items(path)
10109
10342
  if not found:
@@ -10115,7 +10348,8 @@ def _acceptance_traceability(path):
10115
10348
  ids = [i["id"] for i in items if i["id"]]
10116
10349
  if not ids:
10117
10350
  blockers.append("cero criterios trazables — cada criterio lleva ID "
10118
- "estable: '- [ ] AC-01 — cuando X entonces Y'")
10351
+ "estable: '- [ ] AC-01 — cuando X entonces Y' "
10352
+ "(o con familia: '- [ ] AC-BC-01 — ...')")
10119
10353
  return blockers, advisory
10120
10354
  dupes = sorted({x for x in ids if ids.count(x) > 1})
10121
10355
  if dupes:
@@ -10835,7 +11069,8 @@ def cmd_doctor(args):
10835
11069
  elif items:
10836
11070
  warn(f"ACCEPTANCE without traceable AC-IDs ({len(items)} criterion(s))",
10837
11071
  "generate it with /uscha-discovery or /uscha-adr-refine "
10838
- "(format '- [ ] AC-01 - ...') - without IDs the dominant readiness "
11072
+ "(format '- [ ] AC-01 - ...', or with a family "
11073
+ "'- [ ] AC-BC-01 - ...') - without IDs the dominant readiness "
10839
11074
  "dimension falls back to the checkbox ratio")
10840
11075
  else:
10841
11076
  warn(f"ACCEPTANCE {acc} has no criteria (zero checkboxes)")