@andresmassello/uscha 1.99.0 → 2.1.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.99.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
43
+ **Kit v2.1.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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andresmassello/uscha",
3
- "version": "1.99.0",
3
+ "version": "2.1.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",
@@ -255,7 +255,7 @@ Implement per the PLAN. Commit per logical step with conventional commits
255
255
  - **Never edit the SPEC/ADR to make the implementation look correct.** If reality forces
256
256
  a change, amend the SPEC (version it) and return to Ready.
257
257
 
258
- ## Phase 2b — Simplicity gate ("Reduce")
258
+ ## Phase 2b — Simplicity check ("Reduce") — ADVISORY by default (kit 2.1.0)
259
259
 
260
260
  Before the QA loop, check the change isn't overbuilt. This is the CONSTITUTION's
261
261
  **Simplicidad** invariant made deterministic — diff minimality, nesting depth and new
@@ -266,11 +266,25 @@ git diff --unified=0 <base> | python3 $QL simplicity-check --config uscha.config
266
266
  # or: python3 $QL simplicity-check --from-git --base <base>
267
267
  ```
268
268
 
269
- Reads `SIMPLICITY: NN/100 — SIMPLE | ACCEPTABLE | OVERBUILT`. **OVERBUILT (exit 1) is a
270
- BLOCKER**: reduce first (guard clauses, drop speculative types/layers, split giant hunks)
271
- and re-run do not carry it into the QA loop or converge on it. The flags tell you exactly
272
- what to cut. Budgets live in `config.defaults.simplicity` (tighten per risk profile). For a
273
- 2-space codebase pass `--indent-width 2`.
269
+ Reads `SIMPLICITY: NN/100 — SIMPLE | ACCEPTABLE | OVERBUILT (advisory | declared gate)`.
270
+
271
+ **Advisory is the default and it exits 0** (ADR-043). Every budget is the KIT'S OPINION until
272
+ the project declares its own; an opinion that stops a loop is a gate nobody asked for. In
273
+ advisory mode an OVERBUILT verdict is **information for the human**: cut what is cheap to cut,
274
+ **report it in the PR body** with the score and the flags, and **never block on it** — do not
275
+ loop, do not refuse to converge, do not "fix" the diff to chase the number.
276
+
277
+ **It gates only when the project says so**: at least one declared budget in
278
+ `config.defaults.simplicity` **AND** `defaults.simplicity.gate: true` (or `--gate`). Then
279
+ OVERBUILT is exit 1 and a BLOCKER again: reduce first (guard clauses, drop speculative
280
+ types/layers, split giant hunks) and re-run. `gate: true` with **no** budget declared is a
281
+ config error, exit 2 — a gate with no budget is not a gate.
282
+
283
+ **`max_nesting` is an INDENTATION-DEPTH proxy, not AST nesting** — it counts leading
284
+ indentation on added lines. A wrapped call argument, JSX, or a multi-line Java/Kotlin literal
285
+ raises it with no control flow present at all, which is the single most common false OVERBUILT.
286
+ Discount it accordingly; the kit does not try to make it language-aware. For a 2-space codebase
287
+ pass `--indent-width 2`.
274
288
 
275
289
  **Tests are OUTSIDE the budget** (kit 1.11.0): test files (the 9 stack conventions) are
276
290
  counted and reported apart (`test_lines_added`) but never gate — writing tests must not
@@ -278,13 +292,22 @@ push a diff toward OVERBUILT (deleting them is already blocked by gate-check). A
278
292
  project can have MORE test code than production code.
279
293
 
280
294
  **Persist the verdict** so convergence and readiness see it (facts block through the
281
- ledger, not through your goodwill):
295
+ ledger, not through your goodwill) — and persist it as what it WAS:
282
296
 
283
297
  ```bash
298
+ # advisory mode (the default): the run is recorded, and it caps nothing and blocks nothing
299
+ python3 $QL log-gate --repo <REPO> --iteration <N> --kind simplicity \
300
+ --verdict advisory [--note "OVERBUILT 58/100 — advisory, no budget declared"]
301
+
302
+ # declared gate only (defaults.simplicity.gate: true + budgets)
284
303
  python3 $QL log-gate --repo <REPO> --iteration <N> --kind simplicity \
285
304
  --verdict <pass|fail> [--note "OVERBUILT: +612 lines vs 400 budget"]
286
305
  ```
287
306
 
307
+ **Never log an advisory run as `pass`.** `pass` means a declared gate ran and came back clean;
308
+ an advisory run means there was no gate. Readiness prints them apart (`N ok · 1 advisory`) and
309
+ the mirador shows `ADVISORY` instead of `OK` — but only if you tell it the truth here.
310
+
288
311
  ### Phase 2c — REUSE-FIRST gate (kit 1.26.0)
289
312
 
290
313
  Simplicity scores the diff in ISOLATION; it cannot see that the new block re-implements
@@ -314,7 +337,16 @@ profile A (trivial change) skip it. It **collapses into `readiness`** as a `gate
314
337
  ## Phase 3 — QA loop (per repo)
315
338
 
316
339
  Run the tools in `config.defaults.qa_tools_order` (default: code-review → judgment-day
317
- → improve). One pass of all tools = one cycle. After **each** tool pass:
340
+ → improve). One pass of all tools = one cycle.
341
+
342
+ Read the EFFECTIVE order, not the config file: since 2.0.0 `uscha init` generates a minimal
343
+ config, so `qa_tools_order` is often absent there and resolves from `defaults.risk_profile`
344
+ (ADR-001) or from the engine default. `qa_ledger.py doctor --json` prints it as
345
+ `effective.qa_tools_order` with its `origin`; the ledger froze the same value at `init`. A
346
+ project on **profile A runs `code-review` only** — do NOT invoke judgment-day or improve there,
347
+ and convergence must not wait for them.
348
+
349
+ After **each** tool pass:
318
350
 
319
351
  1. Apply only fixes at/above the severity gate. Send the rest to `ISSUES-DEFERRED.md`.
320
352
  2. Run the repo test command. If red and the fix isn't obvious → escalate.
@@ -44,7 +44,8 @@ Usage (see `--help` on each subcommand):
44
44
  qa_ledger.py rebuild --mode baseline --config uscha.config.json [--out REBUILD-BASELINE.json]
45
45
  qa_ledger.py rebuild --mode compare --baseline REBUILD-BASELINE.json [--json]
46
46
  qa_ledger.py simplicity-check --diff changes.diff [--config uscha.config.json] [--json]
47
- qa_ledger.py simplicity-check --from-git --base main
47
+ qa_ledger.py simplicity-check --from-git --base main (advisory: exit 0)
48
+ qa_ledger.py simplicity-check --from-git --base main --max-lines-added 400 --gate
48
49
  qa_ledger.py pit-check --report target/pit-reports/*/mutations.xml [--min-score 60] [--json]
49
50
  qa_ledger.py gate-check --from-git --base main [--strict] [--json]
50
51
  qa_ledger.py spec-check --spec SPEC.md [--spec ACCEPTANCE.md] [--strict] [--json]
@@ -1911,6 +1912,53 @@ def _apply_risk_profile(defaults):
1911
1912
  return defaults
1912
1913
 
1913
1914
 
1915
+ # The engine's OWN value for each knob a risk profile owns (kit 2.0.0): the bottom rung of
1916
+ # the precedence ladder -- explicit override > selected profile > engine default -- as `doctor`
1917
+ # reports it, so the origin of every effective value is visible without reading the source.
1918
+ #
1919
+ # It is a REPORTING table and is deliberately NEVER materialized into `defaults`. Writing an
1920
+ # engine default into the config would make the kit's own value indistinguishable from a human
1921
+ # declaration, which is precisely what `thresholds_declared` / `declared_caps` exist to keep
1922
+ # apart (provenance, kit 1.17.0) -- and precisely the confusion that let a COPIED config
1923
+ # outrank the profile until 2.0.0. The fix for that was to stop copying, not to start
1924
+ # injecting: `uscha init` now generates a minimal config (install-uscha.py), and a knob it
1925
+ # leaves out keeps meaning "not declared" everywhere the engine reads it.
1926
+ #
1927
+ # qa_tools_order is None on purpose: with no list declared, convergence falls back to a window
1928
+ # of --tools-per-cycle agent steps, so there is no default list to name and saying otherwise
1929
+ # would be a narrated claim.
1930
+ ENGINE_DEFAULTS = {
1931
+ "qa_tools_order": None,
1932
+ "coverage_threshold": 60,
1933
+ "golden_required": False,
1934
+ }
1935
+ ENGINE_DEFAULT_NOTES = {
1936
+ "qa_tools_order": "not declared - convergence uses a window of --tools-per-cycle "
1937
+ "agent steps",
1938
+ }
1939
+
1940
+
1941
+ def _resolved_defaults(cfg):
1942
+ """(resolved defaults, origin per profile-owned key) for a RAW config -- the effective
1943
+ settings `doctor` reports. Origin is `override` (declared in defaults), `profile <X>`, or
1944
+ `default`. Read-only: never mutates cfg and never writes anything back."""
1945
+ raw = cfg.get("defaults") if isinstance(cfg, dict) else None
1946
+ raw = dict(raw) if isinstance(raw, dict) else {}
1947
+ declared = set(raw)
1948
+ profile = raw.get("risk_profile")
1949
+ expanded = _apply_risk_profile(dict(raw))
1950
+ resolved, origin = {}, {}
1951
+ for key, fallback in ENGINE_DEFAULTS.items():
1952
+ resolved[key] = expanded[key] if key in expanded else fallback
1953
+ if key in declared:
1954
+ origin[key] = "override"
1955
+ elif profile and key in RISK_PROFILES.get(profile, {}):
1956
+ origin[key] = "profile %s" % profile
1957
+ else:
1958
+ origin[key] = "default"
1959
+ return resolved, origin
1960
+
1961
+
1914
1962
  def _validate_init_config(cfg):
1915
1963
  """Validate only the engine's core init contract before creating a ledger."""
1916
1964
  if not isinstance(cfg, dict):
@@ -1921,6 +1969,9 @@ def _validate_init_config(cfg):
1921
1969
  # expand a named risk profile into concrete knobs BEFORE validating them, so the merged
1922
1970
  # values (qa_tools_order, coverage_threshold, golden_required) flow through the checks
1923
1971
  # below. Explicit config wins per key; an unknown profile fails loud (INV-RISK-01).
1972
+ # Nothing else is written into `defaults`: a key the human did not declare and the profile
1973
+ # did not supply stays ABSENT, so provenance can still tell the kit's default apart from a
1974
+ # declaration (1.17.0). ENGINE_DEFAULTS is what `doctor` reports, never what init freezes.
1924
1975
  _apply_risk_profile(defaults)
1925
1976
  if "golden_required" in defaults and not isinstance(defaults["golden_required"], bool):
1926
1977
  raise SystemExit("[qa_ledger] invalid config: golden_required must be a boolean")
@@ -2447,6 +2498,11 @@ def _gate_rollup(ledger):
2447
2498
  for tool, rec in _latest_static_by_tool(rnode).items():
2448
2499
  gates.append({"repo": rname, "tool": tool,
2449
2500
  "blocking": rec.get("gated_reported", 0) > 0,
2501
+ # ADR-043: an advisory record is non-blocking BY CONSTRUCTION, which
2502
+ # is not the same fact as a gate that ran and came back clean. It
2503
+ # travels so no consumer has to guess which of the two it is looking
2504
+ # at -- the false-clean is the failure mode, not the absence.
2505
+ "advisory": bool(rec.get("advisory")),
2450
2506
  "gated": rec.get("gated_reported", 0),
2451
2507
  "note": rec.get("note")})
2452
2508
  return sorted(gates, key=lambda g: (g["repo"], g["tool"]))
@@ -2674,11 +2730,18 @@ def cmd_spec_change_request(args):
2674
2730
  (row["id"], args.repo, args.source, args.requested_change))
2675
2731
 
2676
2732
 
2677
- def _append_gate_record(ledger, node, repo, tool, iteration, failing, count, note):
2733
+ def _append_gate_record(ledger, node, repo, tool, iteration, failing, count, note,
2734
+ advisory=False):
2678
2735
  """Append a static-gate-shaped record for a FACT gate so the EXISTING plumbing
2679
2736
  sees it: _gate_open_and_sev feeds the BLOCKER/CRITICAL readiness cap (<=65) and
2680
2737
  _converged refuses while the latest record for the tool is failing. A later
2681
- clean record for the same tool clears it (latest-per-tool wins)."""
2738
+ clean record for the same tool clears it (latest-per-tool wins).
2739
+
2740
+ advisory=True (kit 2.1.0, ADR-043) records a MEASUREMENT that is not a gate: the record
2741
+ carries zero gated findings, so it can neither cap readiness nor block convergence, and it
2742
+ is stamped so no surface can render it as a clean gate either. That distinction is the whole
2743
+ point -- a check running advisory is not the same fact as a check running green, and a
2744
+ ledger that cannot tell them apart is the false-clean this flag exists to refuse."""
2682
2745
  ledger["step_counter"] += 1
2683
2746
  n = max(1, count) if failing else 0
2684
2747
  rec = {
@@ -2690,19 +2753,38 @@ def _append_gate_record(ledger, node, repo, tool, iteration, failing, count, not
2690
2753
  "tests_passed": None, "files_changed": 0,
2691
2754
  "fingerprint": None, "finding_ids": None, "note": note,
2692
2755
  }
2756
+ if advisory:
2757
+ rec["advisory"] = True
2693
2758
  node["iterations"].append(rec)
2694
2759
  ledger["steps"].append({"n": rec["n"], "at": rec["at"], "kind": "static-gate",
2695
2760
  "repo": repo, "tool": tool, "iteration": iteration})
2696
2761
  return rec
2697
2762
 
2698
2763
 
2764
+ # The only --kind values log-gate accepts with --verdict advisory (ADR-043): the checks whose
2765
+ # default mode IS advisory. Every other kind is a FACT gate and records pass/fail/not-run only.
2766
+ ADVISORY_CAPABLE_KINDS = ("simplicity", "waste")
2767
+
2768
+
2699
2769
  def cmd_log_gate(args):
2700
2770
  """Persist a FACT-gate verdict (golden-diff / gate-check / pit-check / simplicity / regression)
2701
2771
  into the ledger, so 'facts may block' is enforced by the engine, not by goodwill.
2702
- fail -> BLOCKER record: trips the <=65 readiness cap AND blocks convergence.
2703
- pass -> clean record for the same tool: credits the fix, convergence sees clean.
2704
- not-run -> a steps event ONLY, never an iterations record: absence is not
2705
- evidence — it neither reads as clean nor fakes a red (last state stands).
2772
+ fail -> BLOCKER record: trips the <=65 readiness cap AND blocks convergence.
2773
+ pass -> clean record for the same tool: credits the fix, convergence sees clean.
2774
+ advisory -> a MEASURED, non-gating record (kit 2.1.0, ADR-043): zero gated findings, so
2775
+ it can never cap readiness nor block convergence, and stamped `advisory` so
2776
+ no surface counts it as an `ok` gate. Use it for a check the project has not
2777
+ declared as a gate -- `simplicity-check` in its default advisory mode above
2778
+ all. The alternative (persisting an advisory as `pass`) is the false clean
2779
+ this verdict exists to refuse: a reader cannot tell "the gate was green"
2780
+ from "there was no gate", and the second is what actually happened.
2781
+ not-run -> a steps event ONLY, never an iterations record: absence is not
2782
+ evidence — it neither reads as clean nor fakes a red (last state stands).
2783
+
2784
+ The engine cannot observe which mode a SEPARATE `simplicity-check` process ran in, so this
2785
+ is a named verdict rather than a refusal: refusing would only be enforceable on trust,
2786
+ while a third verdict is enforceable on the ledger. The caller declares the mode; every
2787
+ reader downstream then sees it as a fact instead of inferring it.
2706
2788
  """
2707
2789
  # INV-ADVISORY-01 note (ADR-014): --kind is a CLOSED vocabulary (argparse choices), so
2708
2790
  # an advisory-class dimension (e.g. "semantic") cannot be registered as a gate through
@@ -2724,12 +2806,27 @@ def cmd_log_gate(args):
2724
2806
  f"(no evidence — last logged state stands, absence is never green)")
2725
2807
  return
2726
2808
  failing = args.verdict == "fail"
2809
+ advisory = args.verdict == "advisory"
2810
+ if advisory and args.kind not in ADVISORY_CAPABLE_KINDS:
2811
+ # ADR-043 widens --verdict for the checks that RUN advisory by default. A FACT gate
2812
+ # (deleted tests, a lowered threshold, a golden drift) recorded as advisory would be
2813
+ # a mandatory gate cleared by goodwill -- the exact thing this ledger exists to refuse.
2814
+ print(f"[qa_ledger] log-gate: --verdict advisory is not accepted for --kind {args.kind}: "
2815
+ f"only {', '.join(ADVISORY_CAPABLE_KINDS)} run in an advisory mode; a FACT gate "
2816
+ f"records pass, fail or not-run", file=sys.stderr)
2817
+ sys.exit(2)
2727
2818
  rec = _append_gate_record(ledger, node, args.repo, tool, args.iteration,
2728
- failing, args.count, args.note)
2819
+ failing, args.count, args.note, advisory=advisory)
2729
2820
  _save(args.ledger, ledger)
2730
- state = f"FAIL (BLOCKER x{rec['gated_reported']})" if failing else "PASS (clean)"
2731
- print(f"[qa_ledger] {args.repo}/{tool}: {state} logged "
2732
- f"{'caps readiness <=65 and blocks convergence' if failing else 'clears the gate for convergence'}")
2821
+ if advisory:
2822
+ state, effect = "ADVISORY (measured, not gating)", (
2823
+ "reported everywhere as advisory, never as ok; caps nothing, blocks nothing")
2824
+ elif failing:
2825
+ state, effect = (f"FAIL (BLOCKER x{rec['gated_reported']})",
2826
+ "caps readiness <=65 and blocks convergence")
2827
+ else:
2828
+ state, effect = "PASS (clean)", "clears the gate for convergence"
2829
+ print(f"[qa_ledger] {args.repo}/{tool}: {state} logged — {effect}")
2733
2830
 
2734
2831
 
2735
2832
  def cmd_flag_blocker(args):
@@ -8349,17 +8446,21 @@ def cmd_dashboard(args):
8349
8446
  subscores = [{"k": "coverage",
8350
8447
  "val": round(covp) if isinstance(covp, (int, float)) else None,
8351
8448
  "bd": (f"{round(covp)}%" if isinstance(covp, (int, float)) else None)}]
8352
- gate_block, gate_note = {}, {}
8449
+ gate_block, gate_note, gate_adv = {}, {}, {}
8353
8450
  for g in rd.get("gates", []):
8354
8451
  kind = (g.get("tool") or "").replace("gate:", "")
8355
8452
  key = "golden" if kind.startswith("golden") else kind
8356
8453
  gate_block[key] = gate_block.get(key, False) or bool(g.get("blocking"))
8454
+ gate_adv[key] = gate_adv.get(key, False) or bool(g.get("advisory"))
8357
8455
  if g.get("note") and key not in gate_note:
8358
8456
  gate_note[key] = g.get("note")
8359
8457
  for key in ("simplicity", "waste", "golden"):
8360
8458
  if key in gate_block:
8459
+ # ADR-043: a non-blocking ADVISORY is not "OK" — OK means a declared gate ran clean.
8460
+ _bd = ("FAIL" if gate_block[key]
8461
+ else "ADVISORY" if gate_adv.get(key) else "OK")
8361
8462
  subscores.append({"k": key, "val": None,
8362
- "bd": gate_note.get(key) or ("FAIL" if gate_block[key] else "OK")})
8463
+ "bd": gate_note.get(key) or _bd})
8363
8464
 
8364
8465
  # loops: iters + estado por repo (escalated > converged > active). max sin fuente.
8365
8466
  # El estado se deriva ENTERO con _derive_phase (kit 1.48.1) — la MISMA funcion que
@@ -8983,6 +9084,10 @@ def _top_events(ledger, limit=TOP_EVENTS_TAIL):
8983
9084
  gated = it.get("gated_reported")
8984
9085
  if kind == "gate-not-run":
8985
9086
  tail = "not run — nobody measured it"
9087
+ elif kind == "static-gate" and it.get("advisory"):
9088
+ # ADR-043: measured but not gating. `info` (never green, never red) is the
9089
+ # honest level -- rendering it `pass`/`clean` is the false clean again.
9090
+ level, tail = "info", "advisory — measured, not gating"
8986
9091
  elif kind == "static-gate" and isinstance(gated, int):
8987
9092
  level = "fail" if gated >= 1 else "pass"
8988
9093
  tail = "%d gated finding(s)" % gated if gated else "clean"
@@ -9907,13 +10012,21 @@ def cmd_readiness(args):
9907
10012
  gate_roll = out["gates"]
9908
10013
  if gate_roll:
9909
10014
  blocking = [g for g in gate_roll if g["blocking"]]
9910
- n_ok = len(gate_roll) - len(blocking)
10015
+ # ADR-043: an advisory NEVER joins the ok count. "3 ok" must mean three gates ran and
10016
+ # came back clean; folding a check the project never declared as a gate into that number
10017
+ # is the false clean the advisory verdict exists to refuse. The segment is conditional,
10018
+ # so a ledger with no advisory record prints exactly what it printed before.
10019
+ advisory = [g for g in gate_roll if g.get("advisory") and not g["blocking"]]
10020
+ n_ok = len(gate_roll) - len(blocking) - len(advisory)
10021
+ adv_str = (f" · {len(advisory)} advisory ("
10022
+ + ", ".join(f"{g['repo']}/{g['tool']}" for g in advisory) + ")"
10023
+ if advisory else "")
9911
10024
  hint = "" if args.verbose else " (readiness --verbose for the detail)"
9912
10025
  if blocking:
9913
10026
  names = ", ".join(f"{g['repo']}/{g['tool']}" for g in blocking)
9914
- print(f"--- gates: {n_ok} ok · {len(blocking)} blocking ({names}){hint}")
10027
+ print(f"--- gates: {n_ok} ok{adv_str} · {len(blocking)} blocking ({names}){hint}")
9915
10028
  else:
9916
- print(f"--- gates: {n_ok} ok, none blocking{hint}")
10029
+ print(f"--- gates: {n_ok} ok{adv_str}, none blocking{hint}")
9917
10030
  if not args.verbose:
9918
10031
  return
9919
10032
  print("--- dimensions (weight | raw | contribution) ---")
@@ -9973,6 +10086,14 @@ DEFAULT_COVERAGE_TOLERANCE = 5.0 # pct points the rebuilt coverage may drop
9973
10086
  # abstraction is INTENTIONALLY not weighted: the "new types" regex is a prose/AST proxy
9974
10087
  # that false-positives on Java records/DTOs, so it must not gate the band. It stays as an
9975
10088
  # advisory metric + flag only (distilled: hard caps gate, guessy proxies advise).
10089
+ #
10090
+ # ADVISORY BY DEFAULT (kit 2.1.0, ADR-043). Every budget below is the KIT'S OPINION, not the
10091
+ # project's requirement, and an opinion that exits 1 is a gate nobody declared. Until a project
10092
+ # declares at least one numeric budget AND `defaults.simplicity.gate: true`, the verdict is
10093
+ # reported and the exit code is 0. This is NOT INV-ADVISORY-01 (that invariant quarantines
10094
+ # LLM-class JUDGMENT; these proxies are deterministic and may gate the moment a human says so)
10095
+ # -- it is the provenance rule of 1.17.0 applied to an exit code: a default is an opinion, and
10096
+ # only a declaration is a requirement.
9976
10097
  SIMPLICITY_WEIGHTS = {
9977
10098
  "diff_size": 35, "nesting": 30, "net_growth": 20, "fan_out": 8, "blob": 7,
9978
10099
  }
@@ -9987,6 +10108,17 @@ SIMPLICITY_DEFAULTS = {
9987
10108
  "max_abstraction_density": 3.0, # new *types* per 100 added LOC
9988
10109
  "indent_width": 4,
9989
10110
  }
10111
+ # Keys under defaults.simplicity that are NOT budgets, so declaring one never satisfies the
10112
+ # "a gate needs a budget" rule: `indent_width` is a PARSING parameter and `gate` is the switch
10113
+ # itself. `gate: true` with nothing but these declared is a refusal, not a gate (ADR-043).
10114
+ _SIMPLICITY_NON_BUDGET = ("indent_width", "gate")
10115
+ # What `max_nesting` actually measures, said once and reused by every surface that prints it.
10116
+ # It is INDENTATION DEPTH over added lines, not AST nesting: a wrapped call argument, JSX, a
10117
+ # multi-line Java string or any deep continuation raises it without any control flow existing.
10118
+ # The kit does NOT make it language-aware (that needs a parser per stack, which this stdlib
10119
+ # engine will not have) -- it names the proxy instead, so a reader can discount it.
10120
+ _NESTING_PROXY_NOTE = ("indentation depth over added lines, NOT AST nesting -- continuation "
10121
+ "lines, JSX and multi-line literals inflate it")
9990
10122
  # code files only — docs, config, resources and generated trees are noise for a
9991
10123
  # code-simplicity gate. Broader than SOURCE_EXT (which is repo-typed for rebuild).
9992
10124
  _SIMPLICITY_CODE_EXT = {
@@ -10399,8 +10531,9 @@ def _simplicity_score(m, b):
10399
10531
  def _simplicity_flags(m, b):
10400
10532
  f = []
10401
10533
  if m["max_nesting"] > b["max_nesting_depth"]:
10402
- f.append(f"nesting {m['max_nesting']} > {b['max_nesting_depth']} — "
10403
- f"aplanar: guard clauses / extraer función (CWE-1124)")
10534
+ f.append(f"max_nesting (indentation proxy) {m['max_nesting']} > "
10535
+ f"{b['max_nesting_depth']} — aplanar: guard clauses / extraer función "
10536
+ f"(CWE-1124). Proxy: {_NESTING_PROXY_NOTE}")
10404
10537
  if m["new_abstractions"] > b["max_new_abstractions"]:
10405
10538
  f.append(f"{m['new_abstractions']} tipos/capas nuevos > "
10406
10539
  f"{b['max_new_abstractions']} — ¿todos pedidos? "
@@ -10426,10 +10559,12 @@ def _simplicity_flags(m, b):
10426
10559
  def cmd_simplicity_check(args):
10427
10560
  b = dict(SIMPLICITY_DEFAULTS)
10428
10561
  declared = set() # presupuestos declarados por el humano (config o CLI)
10562
+ gate = False # ADR-043: solo lo enciende una DECLARACION, nunca un default
10429
10563
  if args.config and os.path.exists(args.config):
10430
10564
  cfg = _load(args.config).get("defaults", {}).get("simplicity", {})
10431
10565
  b.update({k: cfg[k] for k in b if k in cfg})
10432
- declared |= {k for k in b if k in cfg and k != "indent_width"}
10566
+ declared |= {k for k in b if k in cfg and k not in _SIMPLICITY_NON_BUDGET}
10567
+ gate = bool(cfg.get("gate"))
10433
10568
  for k in ("max_lines_added", "max_net_lines", "max_files_changed",
10434
10569
  "max_nesting_depth", "max_hunk_added", "max_new_abstractions",
10435
10570
  "indent_width"):
@@ -10441,34 +10576,55 @@ def cmd_simplicity_check(args):
10441
10576
  if args.max_abstraction_density is not None:
10442
10577
  b["max_abstraction_density"] = args.max_abstraction_density
10443
10578
  declared.add("max_abstraction_density")
10579
+ if getattr(args, "gate", False):
10580
+ gate = True
10581
+ # A gate with no budget is not a gate: it is the kit's opinion wearing an exit code, which
10582
+ # is exactly the defect ADR-043 exists to remove. Refuse BEFORE reading the diff -- a
10583
+ # misconfigured gate must not produce a score anyone could quote.
10584
+ if gate and not declared:
10585
+ print("[qa_ledger] invalid config: defaults.simplicity.gate is true (or --gate was "
10586
+ "passed) but no simplicity budget is declared — a gate with no budget is not a "
10587
+ "gate, only the kit's opinion with an exit code. Declare at least one of "
10588
+ "max_lines_added, max_net_lines, max_files_changed, max_nesting_depth, "
10589
+ "max_hunk_added, max_new_abstractions, max_abstraction_density in "
10590
+ "defaults.simplicity (or pass the matching --max-... flag), or set gate to false.",
10591
+ file=sys.stderr)
10592
+ sys.exit(2)
10593
+ mode = "gate" if gate else "advisory"
10444
10594
 
10445
10595
  m = _simplicity_metrics(_read_diff(args), b["indent_width"])
10446
10596
  score, dims = _simplicity_score(m, b)
10447
10597
  verdict = _simplicity_band(score)
10448
10598
  flags = _simplicity_flags(m, b)
10599
+ exit_code = 1 if (verdict == "OVERBUILT" and gate) else 0
10449
10600
 
10450
- out = {"score": score, "verdict": verdict, "weights": SIMPLICITY_WEIGHTS,
10601
+ out = {"score": score, "verdict": verdict, "mode": mode, "gate": gate,
10602
+ "weights": SIMPLICITY_WEIGHTS,
10451
10603
  "dimensions": {k: round(v, 3) for k, v in dims.items()},
10452
- "metrics": m, "budgets": b, "budgets_declared": sorted(declared),
10604
+ "metrics": m, "metrics_notes": {"max_nesting": _NESTING_PROXY_NOTE},
10605
+ "budgets": b, "budgets_declared": sorted(declared),
10453
10606
  "flags": flags}
10454
10607
  if args.json:
10455
10608
  print(json.dumps(out, indent=2, ensure_ascii=False))
10456
- sys.exit(0 if verdict != "OVERBUILT" else 1)
10609
+ sys.exit(exit_code)
10457
10610
 
10458
- print(f"SIMPLICITY: {score}/100 {verdict}")
10611
+ mode_str = ("declared gate" if gate else
10612
+ "advisory (declare budgets + defaults.simplicity.gate to make it block)")
10613
+ print(f"SIMPLICITY: {score}/100 — {verdict} ({mode_str})")
10459
10614
  print("--- metrics (value / budget · * = declared by the human) ---")
10460
10615
  rows = [
10461
10616
  ("lines_added", m["lines_added"], b["max_lines_added"], "max_lines_added"),
10462
10617
  ("net_lines", m["net_lines"], b["max_net_lines"], "max_net_lines"),
10463
10618
  ("files_changed", m["files_changed"], b["max_files_changed"], "max_files_changed"),
10464
- ("max_nesting", m["max_nesting"], b["max_nesting_depth"], "max_nesting_depth"),
10619
+ ("max_nesting (indentation proxy)", m["max_nesting"], b["max_nesting_depth"], "max_nesting_depth"),
10465
10620
  ("new_abstractions", m["new_abstractions"], b["max_new_abstractions"], "max_new_abstractions"),
10466
10621
  ("abstraction/100", m["abstraction_density"], b["max_abstraction_density"], "max_abstraction_density"),
10467
10622
  ("max_hunk_added", m["max_hunk_added"], b["max_hunk_added"], "max_hunk_added"),
10468
10623
  ]
10469
10624
  for name, val, bud, key in rows:
10470
10625
  mark = "*" if key in declared else ""
10471
- print(f" {name:17s} {str(val):>7s} / {bud}{mark}")
10626
+ print(f" {name:31s} {str(val):>7s} / {bud}{mark}")
10627
+ print(f" (max_nesting is a PROXY: {_NESTING_PROXY_NOTE})")
10472
10628
  if not declared:
10473
10629
  print(" (every budget is a kit default — an opinion, not a "
10474
10630
  "requirement: declare yours in config.defaults.simplicity)")
@@ -10485,7 +10641,11 @@ def cmd_simplicity_check(args):
10485
10641
  print(f" ! {fl}")
10486
10642
  else:
10487
10643
  print(" within budget — nothing to cut")
10488
- sys.exit(0 if verdict != "OVERBUILT" else 1)
10644
+ if verdict == "OVERBUILT" and not gate:
10645
+ print("--- advisory: OVERBUILT is REPORTED, not enforced (exit 0). Cut what is cheap, "
10646
+ "say so in the PR body, and do not let it block the loop. To make it block, "
10647
+ "declare your budgets AND defaults.simplicity.gate: true ---")
10648
+ sys.exit(exit_code)
10489
10649
 
10490
10650
 
10491
10651
  # --------------------------------------------------------------------------- #
@@ -12273,6 +12433,7 @@ def cmd_doctor(args):
12273
12433
 
12274
12434
  # --- proyecto (si hay config aca) ---------------------------------------
12275
12435
  qa_order = ["code-review", "judgment-day", "improve"] # default del kit
12436
+ effective, risk_profile = None, None
12276
12437
  cfg_path = args.config or "uscha.config.json"
12277
12438
  if os.path.isfile(cfg_path):
12278
12439
  try:
@@ -12296,7 +12457,50 @@ def cmd_doctor(args):
12296
12457
  warn(f"ACCEPTANCE {acc} has no criteria (zero checkboxes)")
12297
12458
  elif acc:
12298
12459
  warn(f"acceptance_file declared but missing: {acc}")
12299
- qa_order = defaults.get("qa_tools_order", qa_order)
12460
+ # effective settings, with the ORIGIN of each (2.0.0): the three-rung ladder --
12461
+ # explicit override > selected profile > engine default. Reported, never written
12462
+ # back. An override that supersedes the profile is reported as INFORMATION:
12463
+ # declaring a knob by hand is the documented way to bend a preset, never an error.
12464
+ #
12465
+ # An UNKNOWN profile is caught HERE rather than by the config-wide handler below.
12466
+ # `_apply_risk_profile` raises SystemExit on it (INV-RISK-01: a declared risk level
12467
+ # is never inert, and `init` refuses such a config) -- letting that escape would
12468
+ # abandon the toolchain, rubric and ledger checks that follow, so the diagnostic
12469
+ # would go blind on the first bad key instead of naming it. It is a WARN because
12470
+ # that is the verdict doctor gave this config before 2.0.0: resolving the profile
12471
+ # is a new REPORT, and a new report may not silently raise an existing exit code.
12472
+ risk_profile = defaults.get("risk_profile")
12473
+ try:
12474
+ resolved, origin = _resolved_defaults(cfg)
12475
+ except SystemExit:
12476
+ resolved, origin = None, None
12477
+ warn("unknown risk profile %s - no preset applied" % ascii(risk_profile),
12478
+ "valid: " + ", ".join(sorted(RISK_PROFILES))
12479
+ + " - `init` refuses this config (INV-RISK-01); fix it before the loop "
12480
+ "runs. Every other check below still ran.")
12481
+ if origin is not None:
12482
+ effective = {k: {"value": resolved.get(k), "origin": origin[k]}
12483
+ for k in ENGINE_DEFAULTS}
12484
+ ok("risk profile: %s" % (risk_profile or "none declared"),
12485
+ "effective settings below - precedence: override > profile > default")
12486
+ for key in ENGINE_DEFAULTS:
12487
+ value = resolved.get(key)
12488
+ if isinstance(value, list):
12489
+ shown = ", ".join(value)
12490
+ elif value is None:
12491
+ shown = ENGINE_DEFAULT_NOTES.get(key, "not declared")
12492
+ else:
12493
+ shown = value
12494
+ detail = "origin: " + origin[key]
12495
+ if (origin[key] == "override" and risk_profile
12496
+ and key in RISK_PROFILES.get(risk_profile, {})):
12497
+ detail += (" - this override supersedes profile %s (information: an "
12498
+ "explicit declaration is how a preset is bent)"
12499
+ % risk_profile)
12500
+ ok("effective %s = %s" % (key, shown), detail)
12501
+ # the QA-skills check below asks for the tools actually in force; with no list
12502
+ # declared it keeps looking for the kit's three, as it always has
12503
+ qa_order = resolved.get("qa_tools_order") or qa_order
12300
12504
  for r in repos:
12301
12505
  tool = DOCTOR_TOOLS.get(r.get("type", ""))
12302
12506
  if not tool:
@@ -12336,8 +12540,11 @@ def cmd_doctor(args):
12336
12540
  err(f"{cfg_path} invalid", str(exc))
12337
12541
  else:
12338
12542
  warn(f"no {cfg_path} in this directory",
12339
- "install: copy the kit uscha.config.json to the repo root and declare "
12340
- "your repos/types and your quality bar - only needed to RUN the loop here")
12543
+ "install: run `uscha init` here (or `python install-uscha.py init --repo .`) - it "
12544
+ "GENERATES a minimal one and detects the repo; then declare your quality bar. Do "
12545
+ "NOT copy the kit's uscha.config.json: it is a REFERENCE, and every knob in it "
12546
+ "would arrive as an explicit declaration outranking your risk_profile (ADR-001, "
12547
+ "as amended) - only needed to RUN the loop here")
12341
12548
 
12342
12549
  # --- skills de QA del loop (externas al kit, se orquestan sin traerlas) --
12343
12550
  # sin ellas la fase 3 (QA loop) no corre; chequeables con o sin config.
@@ -12361,6 +12568,9 @@ def cmd_doctor(args):
12361
12568
  print(json.dumps({"verdict": "ERROR" if n_err else ("WARN" if n_warn else "OK"),
12362
12569
  "ok": n_ok, "warnings": n_warn, "errors": n_err,
12363
12570
  "global_install": is_global, "plugin_install": is_plugin,
12571
+ # effective settings + origin per knob (2.0.0); null when there is
12572
+ # no project config here to resolve them from
12573
+ "risk_profile": risk_profile, "effective": effective,
12364
12574
  "checks": [{"level": lv, "title": t, "detail": d}
12365
12575
  for lv, t, d in checks]},
12366
12576
  indent=2, ensure_ascii=True))
@@ -12776,7 +12986,11 @@ def build_parser():
12776
12986
  plg.add_argument("--kind", required=True,
12777
12987
  choices=["golden-diff", "gate-check", "pit-check", "simplicity",
12778
12988
  "regression", "rubric", "waste"])
12779
- plg.add_argument("--verdict", required=True, choices=["pass", "fail", "not-run"])
12989
+ plg.add_argument("--verdict", required=True,
12990
+ choices=["pass", "fail", "advisory", "not-run"],
12991
+ help="advisory (ADR-043) records a measured, non-gating run: it never "
12992
+ "caps readiness, never blocks convergence, and never reads as ok; "
12993
+ "accepted only for --kind simplicity|waste, a FACT gate refuses it")
12780
12994
  plg.add_argument("--count", type=int, default=1,
12781
12995
  help="failing finding count (fail only; default 1)")
12782
12996
  plg.add_argument("--note", default=None)
@@ -12957,7 +13171,9 @@ def build_parser():
12957
13171
 
12958
13172
  ps2 = sub.add_parser(
12959
13173
  "simplicity-check",
12960
- help="Reduce gate: score diff minimality/complexity over a unified diff")
13174
+ help="Reduce gate: score diff minimality/complexity over a unified diff. Advisory "
13175
+ "by default; gates only with --gate or defaults.simplicity.gate AND at least "
13176
+ "one declared budget")
12961
13177
  ps2.add_argument("--diff", help="path to a unified diff (else --from-git or stdin)")
12962
13178
  ps2.add_argument("--from-git", action="store_true",
12963
13179
  help="run `git diff --unified=0 <base>` for the diff")
@@ -12973,6 +13189,10 @@ def build_parser():
12973
13189
  ps2.add_argument("--max-abstraction-density", dest="max_abstraction_density",
12974
13190
  type=float, default=None)
12975
13191
  ps2.add_argument("--indent-width", dest="indent_width", type=int)
13192
+ ps2.add_argument("--gate", action="store_true",
13193
+ help="make an OVERBUILT verdict exit 1 (same switch as "
13194
+ "defaults.simplicity.gate). Requires at least one declared budget: "
13195
+ "without one the run REFUSES with exit 2 (ADR-043)")
12976
13196
  ps2.add_argument("--json", action="store_true")
12977
13197
  ps2.set_defaults(func=cmd_simplicity_check)
12978
13198
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "uscha",
4
- "version": "1.99.0",
4
+ "version": "2.1.0",
5
5
  "displayName": "Uscha",
6
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, 53 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
7
7
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uscha",
3
- "version": "1.99.0",
3
+ "version": "2.1.0",
4
4
  "description": "Uscha spec-driven development methodology for coding agents. Includes npm/npx router.",
5
5
  "author": {
6
6
  "name": "Andres Massello",