@andresmassello/uscha 2.0.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 v2.0.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": "2.0.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
@@ -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]
@@ -2497,6 +2498,11 @@ def _gate_rollup(ledger):
2497
2498
  for tool, rec in _latest_static_by_tool(rnode).items():
2498
2499
  gates.append({"repo": rname, "tool": tool,
2499
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")),
2500
2506
  "gated": rec.get("gated_reported", 0),
2501
2507
  "note": rec.get("note")})
2502
2508
  return sorted(gates, key=lambda g: (g["repo"], g["tool"]))
@@ -2724,11 +2730,18 @@ def cmd_spec_change_request(args):
2724
2730
  (row["id"], args.repo, args.source, args.requested_change))
2725
2731
 
2726
2732
 
2727
- 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):
2728
2735
  """Append a static-gate-shaped record for a FACT gate so the EXISTING plumbing
2729
2736
  sees it: _gate_open_and_sev feeds the BLOCKER/CRITICAL readiness cap (<=65) and
2730
2737
  _converged refuses while the latest record for the tool is failing. A later
2731
- 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."""
2732
2745
  ledger["step_counter"] += 1
2733
2746
  n = max(1, count) if failing else 0
2734
2747
  rec = {
@@ -2740,19 +2753,38 @@ def _append_gate_record(ledger, node, repo, tool, iteration, failing, count, not
2740
2753
  "tests_passed": None, "files_changed": 0,
2741
2754
  "fingerprint": None, "finding_ids": None, "note": note,
2742
2755
  }
2756
+ if advisory:
2757
+ rec["advisory"] = True
2743
2758
  node["iterations"].append(rec)
2744
2759
  ledger["steps"].append({"n": rec["n"], "at": rec["at"], "kind": "static-gate",
2745
2760
  "repo": repo, "tool": tool, "iteration": iteration})
2746
2761
  return rec
2747
2762
 
2748
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
+
2749
2769
  def cmd_log_gate(args):
2750
2770
  """Persist a FACT-gate verdict (golden-diff / gate-check / pit-check / simplicity / regression)
2751
2771
  into the ledger, so 'facts may block' is enforced by the engine, not by goodwill.
2752
- fail -> BLOCKER record: trips the <=65 readiness cap AND blocks convergence.
2753
- pass -> clean record for the same tool: credits the fix, convergence sees clean.
2754
- not-run -> a steps event ONLY, never an iterations record: absence is not
2755
- 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.
2756
2788
  """
2757
2789
  # INV-ADVISORY-01 note (ADR-014): --kind is a CLOSED vocabulary (argparse choices), so
2758
2790
  # an advisory-class dimension (e.g. "semantic") cannot be registered as a gate through
@@ -2774,12 +2806,27 @@ def cmd_log_gate(args):
2774
2806
  f"(no evidence — last logged state stands, absence is never green)")
2775
2807
  return
2776
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)
2777
2818
  rec = _append_gate_record(ledger, node, args.repo, tool, args.iteration,
2778
- failing, args.count, args.note)
2819
+ failing, args.count, args.note, advisory=advisory)
2779
2820
  _save(args.ledger, ledger)
2780
- state = f"FAIL (BLOCKER x{rec['gated_reported']})" if failing else "PASS (clean)"
2781
- print(f"[qa_ledger] {args.repo}/{tool}: {state} logged "
2782
- 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}")
2783
2830
 
2784
2831
 
2785
2832
  def cmd_flag_blocker(args):
@@ -8399,17 +8446,21 @@ def cmd_dashboard(args):
8399
8446
  subscores = [{"k": "coverage",
8400
8447
  "val": round(covp) if isinstance(covp, (int, float)) else None,
8401
8448
  "bd": (f"{round(covp)}%" if isinstance(covp, (int, float)) else None)}]
8402
- gate_block, gate_note = {}, {}
8449
+ gate_block, gate_note, gate_adv = {}, {}, {}
8403
8450
  for g in rd.get("gates", []):
8404
8451
  kind = (g.get("tool") or "").replace("gate:", "")
8405
8452
  key = "golden" if kind.startswith("golden") else kind
8406
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"))
8407
8455
  if g.get("note") and key not in gate_note:
8408
8456
  gate_note[key] = g.get("note")
8409
8457
  for key in ("simplicity", "waste", "golden"):
8410
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")
8411
8462
  subscores.append({"k": key, "val": None,
8412
- "bd": gate_note.get(key) or ("FAIL" if gate_block[key] else "OK")})
8463
+ "bd": gate_note.get(key) or _bd})
8413
8464
 
8414
8465
  # loops: iters + estado por repo (escalated > converged > active). max sin fuente.
8415
8466
  # El estado se deriva ENTERO con _derive_phase (kit 1.48.1) — la MISMA funcion que
@@ -9033,6 +9084,10 @@ def _top_events(ledger, limit=TOP_EVENTS_TAIL):
9033
9084
  gated = it.get("gated_reported")
9034
9085
  if kind == "gate-not-run":
9035
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"
9036
9091
  elif kind == "static-gate" and isinstance(gated, int):
9037
9092
  level = "fail" if gated >= 1 else "pass"
9038
9093
  tail = "%d gated finding(s)" % gated if gated else "clean"
@@ -9957,13 +10012,21 @@ def cmd_readiness(args):
9957
10012
  gate_roll = out["gates"]
9958
10013
  if gate_roll:
9959
10014
  blocking = [g for g in gate_roll if g["blocking"]]
9960
- 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 "")
9961
10024
  hint = "" if args.verbose else " (readiness --verbose for the detail)"
9962
10025
  if blocking:
9963
10026
  names = ", ".join(f"{g['repo']}/{g['tool']}" for g in blocking)
9964
- 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}")
9965
10028
  else:
9966
- print(f"--- gates: {n_ok} ok, none blocking{hint}")
10029
+ print(f"--- gates: {n_ok} ok{adv_str}, none blocking{hint}")
9967
10030
  if not args.verbose:
9968
10031
  return
9969
10032
  print("--- dimensions (weight | raw | contribution) ---")
@@ -10023,6 +10086,14 @@ DEFAULT_COVERAGE_TOLERANCE = 5.0 # pct points the rebuilt coverage may drop
10023
10086
  # abstraction is INTENTIONALLY not weighted: the "new types" regex is a prose/AST proxy
10024
10087
  # that false-positives on Java records/DTOs, so it must not gate the band. It stays as an
10025
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.
10026
10097
  SIMPLICITY_WEIGHTS = {
10027
10098
  "diff_size": 35, "nesting": 30, "net_growth": 20, "fan_out": 8, "blob": 7,
10028
10099
  }
@@ -10037,6 +10108,17 @@ SIMPLICITY_DEFAULTS = {
10037
10108
  "max_abstraction_density": 3.0, # new *types* per 100 added LOC
10038
10109
  "indent_width": 4,
10039
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")
10040
10122
  # code files only — docs, config, resources and generated trees are noise for a
10041
10123
  # code-simplicity gate. Broader than SOURCE_EXT (which is repo-typed for rebuild).
10042
10124
  _SIMPLICITY_CODE_EXT = {
@@ -10449,8 +10531,9 @@ def _simplicity_score(m, b):
10449
10531
  def _simplicity_flags(m, b):
10450
10532
  f = []
10451
10533
  if m["max_nesting"] > b["max_nesting_depth"]:
10452
- f.append(f"nesting {m['max_nesting']} > {b['max_nesting_depth']} — "
10453
- 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}")
10454
10537
  if m["new_abstractions"] > b["max_new_abstractions"]:
10455
10538
  f.append(f"{m['new_abstractions']} tipos/capas nuevos > "
10456
10539
  f"{b['max_new_abstractions']} — ¿todos pedidos? "
@@ -10476,10 +10559,12 @@ def _simplicity_flags(m, b):
10476
10559
  def cmd_simplicity_check(args):
10477
10560
  b = dict(SIMPLICITY_DEFAULTS)
10478
10561
  declared = set() # presupuestos declarados por el humano (config o CLI)
10562
+ gate = False # ADR-043: solo lo enciende una DECLARACION, nunca un default
10479
10563
  if args.config and os.path.exists(args.config):
10480
10564
  cfg = _load(args.config).get("defaults", {}).get("simplicity", {})
10481
10565
  b.update({k: cfg[k] for k in b if k in cfg})
10482
- 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"))
10483
10568
  for k in ("max_lines_added", "max_net_lines", "max_files_changed",
10484
10569
  "max_nesting_depth", "max_hunk_added", "max_new_abstractions",
10485
10570
  "indent_width"):
@@ -10491,34 +10576,55 @@ def cmd_simplicity_check(args):
10491
10576
  if args.max_abstraction_density is not None:
10492
10577
  b["max_abstraction_density"] = args.max_abstraction_density
10493
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"
10494
10594
 
10495
10595
  m = _simplicity_metrics(_read_diff(args), b["indent_width"])
10496
10596
  score, dims = _simplicity_score(m, b)
10497
10597
  verdict = _simplicity_band(score)
10498
10598
  flags = _simplicity_flags(m, b)
10599
+ exit_code = 1 if (verdict == "OVERBUILT" and gate) else 0
10499
10600
 
10500
- out = {"score": score, "verdict": verdict, "weights": SIMPLICITY_WEIGHTS,
10601
+ out = {"score": score, "verdict": verdict, "mode": mode, "gate": gate,
10602
+ "weights": SIMPLICITY_WEIGHTS,
10501
10603
  "dimensions": {k: round(v, 3) for k, v in dims.items()},
10502
- "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),
10503
10606
  "flags": flags}
10504
10607
  if args.json:
10505
10608
  print(json.dumps(out, indent=2, ensure_ascii=False))
10506
- sys.exit(0 if verdict != "OVERBUILT" else 1)
10609
+ sys.exit(exit_code)
10507
10610
 
10508
- 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})")
10509
10614
  print("--- metrics (value / budget · * = declared by the human) ---")
10510
10615
  rows = [
10511
10616
  ("lines_added", m["lines_added"], b["max_lines_added"], "max_lines_added"),
10512
10617
  ("net_lines", m["net_lines"], b["max_net_lines"], "max_net_lines"),
10513
10618
  ("files_changed", m["files_changed"], b["max_files_changed"], "max_files_changed"),
10514
- ("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"),
10515
10620
  ("new_abstractions", m["new_abstractions"], b["max_new_abstractions"], "max_new_abstractions"),
10516
10621
  ("abstraction/100", m["abstraction_density"], b["max_abstraction_density"], "max_abstraction_density"),
10517
10622
  ("max_hunk_added", m["max_hunk_added"], b["max_hunk_added"], "max_hunk_added"),
10518
10623
  ]
10519
10624
  for name, val, bud, key in rows:
10520
10625
  mark = "*" if key in declared else ""
10521
- 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})")
10522
10628
  if not declared:
10523
10629
  print(" (every budget is a kit default — an opinion, not a "
10524
10630
  "requirement: declare yours in config.defaults.simplicity)")
@@ -10535,7 +10641,11 @@ def cmd_simplicity_check(args):
10535
10641
  print(f" ! {fl}")
10536
10642
  else:
10537
10643
  print(" within budget — nothing to cut")
10538
- 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)
10539
10649
 
10540
10650
 
10541
10651
  # --------------------------------------------------------------------------- #
@@ -12876,7 +12986,11 @@ def build_parser():
12876
12986
  plg.add_argument("--kind", required=True,
12877
12987
  choices=["golden-diff", "gate-check", "pit-check", "simplicity",
12878
12988
  "regression", "rubric", "waste"])
12879
- 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")
12880
12994
  plg.add_argument("--count", type=int, default=1,
12881
12995
  help="failing finding count (fail only; default 1)")
12882
12996
  plg.add_argument("--note", default=None)
@@ -13057,7 +13171,9 @@ def build_parser():
13057
13171
 
13058
13172
  ps2 = sub.add_parser(
13059
13173
  "simplicity-check",
13060
- 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")
13061
13177
  ps2.add_argument("--diff", help="path to a unified diff (else --from-git or stdin)")
13062
13178
  ps2.add_argument("--from-git", action="store_true",
13063
13179
  help="run `git diff --unified=0 <base>` for the diff")
@@ -13073,6 +13189,10 @@ def build_parser():
13073
13189
  ps2.add_argument("--max-abstraction-density", dest="max_abstraction_density",
13074
13190
  type=float, default=None)
13075
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)")
13076
13196
  ps2.add_argument("--json", action="store_true")
13077
13197
  ps2.set_defaults(func=cmd_simplicity_check)
13078
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": "2.0.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": "2.0.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",
@@ -1,6 +1,6 @@
1
1
  # uscha-kit
2
2
 
3
- **Kit version:** v2.0.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
3
+ **Kit version:** v2.1.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`,
@@ -621,9 +621,9 @@ python3 $QL rebuild --mode compare --baseline REBUILD-BASELINE.json --json # c
621
621
  - Verdicts: `COVERS ≥90` · `PARTIAL ≥70` · `DIVERGE <70`. The score lists the concrete
622
622
  **gaps** — feed them back into the SPEC and re-run. Divergence is a spec hole, not a code bug.
623
623
 
624
- ## Simplicity gate — "Reduce" (minimality of the change)
624
+ ## Simplicity check — "Reduce" (minimality of the change) — ADVISORY by default
625
625
 
626
- The **Simplicity** invariant of the CONSTITUTION made a deterministic gate: it scores the *diff*
626
+ The **Simplicity** invariant of the CONSTITUTION made deterministic: it scores the *diff*
627
627
  (not CC by AST — they are measurable proxies: minimality, nesting, new abstractions).
628
628
 
629
629
  ```bash
@@ -634,21 +634,35 @@ python3 $QL simplicity-check --diff changes.diff --json # consumed by usc
634
634
 
635
635
  - Dimensions/weights: diff_size 35, nesting 30, net_growth 20, fan_out 8, blob 7
636
636
  (abstraction does NOT weigh in the score — it's a guessy proxy, kept as a metric + advisory flag).
637
- - Verdicts: `SIMPLE ≥85` · `ACCEPTABLE ≥65` · `OVERBUILT <65` (exit 1 = BLOCKER: trim and re-run).
637
+ - Verdicts: `SIMPLE ≥85` · `ACCEPTABLE ≥65` · `OVERBUILT <65`.
638
638
  A gross excess (2× budget, or very deep nesting) caps the score at 60 no matter what.
639
+ - **Advisory by default, exit 0** (kit 2.1.0, ADR-043): every budget above is the KIT's opinion
640
+ until you declare your own, and an opinion that exits 1 is a gate nobody asked for. It
641
+ **gates** — OVERBUILT = exit 1 = BLOCKER — only with at least one budget declared in
642
+ `defaults.simplicity` **AND** `defaults.simplicity.gate: true` (or `--gate`). `gate: true`
643
+ with no budget declared is a config error, exit 2: a gate with no budget is not a gate.
644
+ `log-gate --kind simplicity --verdict advisory` persists an advisory run as an advisory —
645
+ readiness prints `N ok · 1 advisory` and the mirador reads `ADVISORY`, never `OK`.
646
+ - **`max_nesting` is an INDENTATION-DEPTH proxy**, not AST nesting: it reads leading
647
+ indentation on added lines, so a wrapped argument, JSX, or a multi-line Java literal inflates
648
+ it with no control flow present. It is named as a proxy in the report rather than made
649
+ language-aware. 2-space codebase → `--indent-width 2`.
639
650
  - **Tests OUT of the budget** (kit 1.11.0): the test files (conventions of the
640
651
  9 stacks) are counted and reported separately (`test_lines_added`) but do not gate — writing
641
652
  tests never pushes the diff to OVERBUILT (deleting them is already blocked by gate-check).
642
653
  - The flags tell you what to trim (guard clauses, speculative types/layers, giant hunks).
643
- - Budgets in `defaults.simplicity`; adjustable per risk profile. 2-space `--indent-width 2`.
654
+ - Budgets in `defaults.simplicity`. No risk profile owns them: the gate is a human declaration
655
+ under every profile A–E.
644
656
 
645
657
  ## Ledger subcommands
646
658
 
647
659
  `bench - bench-curate - bench-r2 - bench-roundtrip - bootstrap-oracle - bootstrap-variance - check-coverage - check-terminado - cleanroom - compile-ingest - compile-validate - converged - curate - curation-check - dashboard - discover - doctor - escalate - execution-policy - facts - fastpath-eval - fidelity - flag-blocker - gate-check - golden-coverage - golden-diff - ingest-gate - init - ir-extract - ir-render - lang-compare - log-gate - log-step - oscillation - phase - pit-check - production-finding - promote - readiness - rebuild - regression-check - resolve-escalation - roundtrip - rubric-ingest - simplicity-check - snapshot - spec-change-request - spec-check - spec-doubt - spec-drift - summary - top - waste-check` - the exact current `qa_ledger.py` parser surface (53 subcommands, derived from `SYSTEM-FACTS.json`, itself introspected from `build_parser()`); each supports `--help`.
648
660
 
649
661
  The **fact gates** (golden-diff, gate-check, pit-check, simplicity) are PERSISTED with
650
- `log-gate`: a fail blocks convergence and caps readiness ≤65 via the ledger. A CONSTITUTION
651
- violation is recorded with `flag-blocker` (same effect, until `--resolve`).
662
+ `log-gate`: a fail blocks convergence and caps readiness ≤65 via the ledger; `--verdict
663
+ advisory` records a run that is measured but not gating (it caps nothing, blocks nothing, and
664
+ never counts as an `ok` gate). A CONSTITUTION violation is recorded with `flag-blocker` (same
665
+ effect, until `--resolve`).
652
666
 
653
667
  ## Notes
654
668
 
package/uscha-kit/VERSION CHANGED
@@ -1 +1 @@
1
- uscha-kit 2.0.0
1
+ uscha-kit 2.1.0
@@ -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
@@ -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]
@@ -2497,6 +2498,11 @@ def _gate_rollup(ledger):
2497
2498
  for tool, rec in _latest_static_by_tool(rnode).items():
2498
2499
  gates.append({"repo": rname, "tool": tool,
2499
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")),
2500
2506
  "gated": rec.get("gated_reported", 0),
2501
2507
  "note": rec.get("note")})
2502
2508
  return sorted(gates, key=lambda g: (g["repo"], g["tool"]))
@@ -2724,11 +2730,18 @@ def cmd_spec_change_request(args):
2724
2730
  (row["id"], args.repo, args.source, args.requested_change))
2725
2731
 
2726
2732
 
2727
- 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):
2728
2735
  """Append a static-gate-shaped record for a FACT gate so the EXISTING plumbing
2729
2736
  sees it: _gate_open_and_sev feeds the BLOCKER/CRITICAL readiness cap (<=65) and
2730
2737
  _converged refuses while the latest record for the tool is failing. A later
2731
- 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."""
2732
2745
  ledger["step_counter"] += 1
2733
2746
  n = max(1, count) if failing else 0
2734
2747
  rec = {
@@ -2740,19 +2753,38 @@ def _append_gate_record(ledger, node, repo, tool, iteration, failing, count, not
2740
2753
  "tests_passed": None, "files_changed": 0,
2741
2754
  "fingerprint": None, "finding_ids": None, "note": note,
2742
2755
  }
2756
+ if advisory:
2757
+ rec["advisory"] = True
2743
2758
  node["iterations"].append(rec)
2744
2759
  ledger["steps"].append({"n": rec["n"], "at": rec["at"], "kind": "static-gate",
2745
2760
  "repo": repo, "tool": tool, "iteration": iteration})
2746
2761
  return rec
2747
2762
 
2748
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
+
2749
2769
  def cmd_log_gate(args):
2750
2770
  """Persist a FACT-gate verdict (golden-diff / gate-check / pit-check / simplicity / regression)
2751
2771
  into the ledger, so 'facts may block' is enforced by the engine, not by goodwill.
2752
- fail -> BLOCKER record: trips the <=65 readiness cap AND blocks convergence.
2753
- pass -> clean record for the same tool: credits the fix, convergence sees clean.
2754
- not-run -> a steps event ONLY, never an iterations record: absence is not
2755
- 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.
2756
2788
  """
2757
2789
  # INV-ADVISORY-01 note (ADR-014): --kind is a CLOSED vocabulary (argparse choices), so
2758
2790
  # an advisory-class dimension (e.g. "semantic") cannot be registered as a gate through
@@ -2774,12 +2806,27 @@ def cmd_log_gate(args):
2774
2806
  f"(no evidence — last logged state stands, absence is never green)")
2775
2807
  return
2776
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)
2777
2818
  rec = _append_gate_record(ledger, node, args.repo, tool, args.iteration,
2778
- failing, args.count, args.note)
2819
+ failing, args.count, args.note, advisory=advisory)
2779
2820
  _save(args.ledger, ledger)
2780
- state = f"FAIL (BLOCKER x{rec['gated_reported']})" if failing else "PASS (clean)"
2781
- print(f"[qa_ledger] {args.repo}/{tool}: {state} logged "
2782
- 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}")
2783
2830
 
2784
2831
 
2785
2832
  def cmd_flag_blocker(args):
@@ -8399,17 +8446,21 @@ def cmd_dashboard(args):
8399
8446
  subscores = [{"k": "coverage",
8400
8447
  "val": round(covp) if isinstance(covp, (int, float)) else None,
8401
8448
  "bd": (f"{round(covp)}%" if isinstance(covp, (int, float)) else None)}]
8402
- gate_block, gate_note = {}, {}
8449
+ gate_block, gate_note, gate_adv = {}, {}, {}
8403
8450
  for g in rd.get("gates", []):
8404
8451
  kind = (g.get("tool") or "").replace("gate:", "")
8405
8452
  key = "golden" if kind.startswith("golden") else kind
8406
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"))
8407
8455
  if g.get("note") and key not in gate_note:
8408
8456
  gate_note[key] = g.get("note")
8409
8457
  for key in ("simplicity", "waste", "golden"):
8410
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")
8411
8462
  subscores.append({"k": key, "val": None,
8412
- "bd": gate_note.get(key) or ("FAIL" if gate_block[key] else "OK")})
8463
+ "bd": gate_note.get(key) or _bd})
8413
8464
 
8414
8465
  # loops: iters + estado por repo (escalated > converged > active). max sin fuente.
8415
8466
  # El estado se deriva ENTERO con _derive_phase (kit 1.48.1) — la MISMA funcion que
@@ -9033,6 +9084,10 @@ def _top_events(ledger, limit=TOP_EVENTS_TAIL):
9033
9084
  gated = it.get("gated_reported")
9034
9085
  if kind == "gate-not-run":
9035
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"
9036
9091
  elif kind == "static-gate" and isinstance(gated, int):
9037
9092
  level = "fail" if gated >= 1 else "pass"
9038
9093
  tail = "%d gated finding(s)" % gated if gated else "clean"
@@ -9957,13 +10012,21 @@ def cmd_readiness(args):
9957
10012
  gate_roll = out["gates"]
9958
10013
  if gate_roll:
9959
10014
  blocking = [g for g in gate_roll if g["blocking"]]
9960
- 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 "")
9961
10024
  hint = "" if args.verbose else " (readiness --verbose for the detail)"
9962
10025
  if blocking:
9963
10026
  names = ", ".join(f"{g['repo']}/{g['tool']}" for g in blocking)
9964
- 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}")
9965
10028
  else:
9966
- print(f"--- gates: {n_ok} ok, none blocking{hint}")
10029
+ print(f"--- gates: {n_ok} ok{adv_str}, none blocking{hint}")
9967
10030
  if not args.verbose:
9968
10031
  return
9969
10032
  print("--- dimensions (weight | raw | contribution) ---")
@@ -10023,6 +10086,14 @@ DEFAULT_COVERAGE_TOLERANCE = 5.0 # pct points the rebuilt coverage may drop
10023
10086
  # abstraction is INTENTIONALLY not weighted: the "new types" regex is a prose/AST proxy
10024
10087
  # that false-positives on Java records/DTOs, so it must not gate the band. It stays as an
10025
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.
10026
10097
  SIMPLICITY_WEIGHTS = {
10027
10098
  "diff_size": 35, "nesting": 30, "net_growth": 20, "fan_out": 8, "blob": 7,
10028
10099
  }
@@ -10037,6 +10108,17 @@ SIMPLICITY_DEFAULTS = {
10037
10108
  "max_abstraction_density": 3.0, # new *types* per 100 added LOC
10038
10109
  "indent_width": 4,
10039
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")
10040
10122
  # code files only — docs, config, resources and generated trees are noise for a
10041
10123
  # code-simplicity gate. Broader than SOURCE_EXT (which is repo-typed for rebuild).
10042
10124
  _SIMPLICITY_CODE_EXT = {
@@ -10449,8 +10531,9 @@ def _simplicity_score(m, b):
10449
10531
  def _simplicity_flags(m, b):
10450
10532
  f = []
10451
10533
  if m["max_nesting"] > b["max_nesting_depth"]:
10452
- f.append(f"nesting {m['max_nesting']} > {b['max_nesting_depth']} — "
10453
- 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}")
10454
10537
  if m["new_abstractions"] > b["max_new_abstractions"]:
10455
10538
  f.append(f"{m['new_abstractions']} tipos/capas nuevos > "
10456
10539
  f"{b['max_new_abstractions']} — ¿todos pedidos? "
@@ -10476,10 +10559,12 @@ def _simplicity_flags(m, b):
10476
10559
  def cmd_simplicity_check(args):
10477
10560
  b = dict(SIMPLICITY_DEFAULTS)
10478
10561
  declared = set() # presupuestos declarados por el humano (config o CLI)
10562
+ gate = False # ADR-043: solo lo enciende una DECLARACION, nunca un default
10479
10563
  if args.config and os.path.exists(args.config):
10480
10564
  cfg = _load(args.config).get("defaults", {}).get("simplicity", {})
10481
10565
  b.update({k: cfg[k] for k in b if k in cfg})
10482
- 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"))
10483
10568
  for k in ("max_lines_added", "max_net_lines", "max_files_changed",
10484
10569
  "max_nesting_depth", "max_hunk_added", "max_new_abstractions",
10485
10570
  "indent_width"):
@@ -10491,34 +10576,55 @@ def cmd_simplicity_check(args):
10491
10576
  if args.max_abstraction_density is not None:
10492
10577
  b["max_abstraction_density"] = args.max_abstraction_density
10493
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"
10494
10594
 
10495
10595
  m = _simplicity_metrics(_read_diff(args), b["indent_width"])
10496
10596
  score, dims = _simplicity_score(m, b)
10497
10597
  verdict = _simplicity_band(score)
10498
10598
  flags = _simplicity_flags(m, b)
10599
+ exit_code = 1 if (verdict == "OVERBUILT" and gate) else 0
10499
10600
 
10500
- out = {"score": score, "verdict": verdict, "weights": SIMPLICITY_WEIGHTS,
10601
+ out = {"score": score, "verdict": verdict, "mode": mode, "gate": gate,
10602
+ "weights": SIMPLICITY_WEIGHTS,
10501
10603
  "dimensions": {k: round(v, 3) for k, v in dims.items()},
10502
- "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),
10503
10606
  "flags": flags}
10504
10607
  if args.json:
10505
10608
  print(json.dumps(out, indent=2, ensure_ascii=False))
10506
- sys.exit(0 if verdict != "OVERBUILT" else 1)
10609
+ sys.exit(exit_code)
10507
10610
 
10508
- 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})")
10509
10614
  print("--- metrics (value / budget · * = declared by the human) ---")
10510
10615
  rows = [
10511
10616
  ("lines_added", m["lines_added"], b["max_lines_added"], "max_lines_added"),
10512
10617
  ("net_lines", m["net_lines"], b["max_net_lines"], "max_net_lines"),
10513
10618
  ("files_changed", m["files_changed"], b["max_files_changed"], "max_files_changed"),
10514
- ("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"),
10515
10620
  ("new_abstractions", m["new_abstractions"], b["max_new_abstractions"], "max_new_abstractions"),
10516
10621
  ("abstraction/100", m["abstraction_density"], b["max_abstraction_density"], "max_abstraction_density"),
10517
10622
  ("max_hunk_added", m["max_hunk_added"], b["max_hunk_added"], "max_hunk_added"),
10518
10623
  ]
10519
10624
  for name, val, bud, key in rows:
10520
10625
  mark = "*" if key in declared else ""
10521
- 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})")
10522
10628
  if not declared:
10523
10629
  print(" (every budget is a kit default — an opinion, not a "
10524
10630
  "requirement: declare yours in config.defaults.simplicity)")
@@ -10535,7 +10641,11 @@ def cmd_simplicity_check(args):
10535
10641
  print(f" ! {fl}")
10536
10642
  else:
10537
10643
  print(" within budget — nothing to cut")
10538
- 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)
10539
10649
 
10540
10650
 
10541
10651
  # --------------------------------------------------------------------------- #
@@ -12876,7 +12986,11 @@ def build_parser():
12876
12986
  plg.add_argument("--kind", required=True,
12877
12987
  choices=["golden-diff", "gate-check", "pit-check", "simplicity",
12878
12988
  "regression", "rubric", "waste"])
12879
- 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")
12880
12994
  plg.add_argument("--count", type=int, default=1,
12881
12995
  help="failing finding count (fail only; default 1)")
12882
12996
  plg.add_argument("--note", default=None)
@@ -13057,7 +13171,9 @@ def build_parser():
13057
13171
 
13058
13172
  ps2 = sub.add_parser(
13059
13173
  "simplicity-check",
13060
- 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")
13061
13177
  ps2.add_argument("--diff", help="path to a unified diff (else --from-git or stdin)")
13062
13178
  ps2.add_argument("--from-git", action="store_true",
13063
13179
  help="run `git diff --unified=0 <base>` for the diff")
@@ -13073,6 +13189,10 @@ def build_parser():
13073
13189
  ps2.add_argument("--max-abstraction-density", dest="max_abstraction_density",
13074
13190
  type=float, default=None)
13075
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)")
13076
13196
  ps2.add_argument("--json", action="store_true")
13077
13197
  ps2.set_defaults(func=cmd_simplicity_check)
13078
13198
 
@@ -37,9 +37,11 @@ enforcing the record is the engine's job. It is never resolved by "working aroun
37
37
 
38
38
  ## Simplicity — "Reduce" (non-negotiable)
39
39
 
40
- > Maeda's law 1 and Karpathy's "Simplicity First", made a deterministic gate.
40
+ > Maeda's law 1 and Karpathy's "Simplicity First", made a deterministic check.
41
41
  > It is not CC by AST: they are measurable *proxies* over the diff. Measured by
42
- > `qa_ledger.py simplicity-check`; an **OVERBUILT** verdict is a **BLOCKER** finding.
42
+ > `qa_ledger.py simplicity-check`, **advisory by default** (exit 0); with your own budgets
43
+ > declared **and** `defaults.simplicity.gate: true`, an **OVERBUILT** verdict is a
44
+ > **BLOCKER** finding.
43
45
 
44
46
  - [ ] Minimal code that solves what was asked — no unrequested features, layers or "flexibility" <!-- YAGNI / speculative generality -->
45
47
  - [ ] No speculative abstractions — every new type/layer is justified against the SPEC <!-- YAGNI -->
@@ -133,7 +135,12 @@ enforcing the record is the engine's job. It is never resolved by "working aroun
133
135
  Detecting it is the agent/human's obligation; once recorded, enforcement is the engine's.
134
136
  - The **Simplicity** invariant is measured without human judgment: `qa_ledger.py simplicity-check`
135
137
  scores the diff (minimality, nesting, abstraction) and returns `SIMPLE / ACCEPTABLE /
136
- OVERBUILT`. **OVERBUILT** = BLOCKER (exit 1): it is trimmed, not converged.
138
+ OVERBUILT`. **Advisory by default** (exit 0, kit 2.1.0): the shipped budgets are the kit's
139
+ opinion, and an opinion never blocks a loop. With your own budgets declared in
140
+ `defaults.simplicity` **and** `defaults.simplicity.gate: true` (or `--gate`), **OVERBUILT** =
141
+ BLOCKER (exit 1): it is trimmed, not converged. `gate: true` without a declared budget is
142
+ refused (exit 2). Note that `max_nesting` is an INDENTATION-DEPTH proxy, so JSX and
143
+ multi-line literals inflate it — read it as such before trimming.
137
144
  - The **Reuse (REUSE-FIRST)** invariant is measured by `qa_ledger.py waste-check`: Type-1/2 clones
138
145
  of the diff vs the repo (`dup_vs_repo` is the dominant signal). **Advisory by default** (advises
139
146
  with `file:line` to reuse, exit 0); with `defaults.waste.gate: true` or `--gate` a
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "COMPREHENSIVE REFERENCE, not a project config. Every knob the engine and the skills understand, at the kit's own value, so a human can read what can be declared. `uscha init` does NOT copy this file: it GENERATES a minimal project config, because a copied default is an explicit declaration and an explicit declaration outranks the preset named by defaults.risk_profile (ADR-001, as amended). Copy a block from here into your project only when you mean to override the engine default or the preset. NOTE: no version string may be written into this comment -- the release script requires exactly one occurrence of the version in this file (I3), and a second one refuses the next release.",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "project": null,
5
5
  "defaults": {
6
6
  "coverage_threshold": 60,
@@ -73,7 +73,8 @@
73
73
  "max_hunk_added": 120,
74
74
  "max_new_abstractions": 8,
75
75
  "max_abstraction_density": 3.0,
76
- "indent_width": 4
76
+ "indent_width": 4,
77
+ "gate": false
77
78
  },
78
79
  "waste": {
79
80
  "window_size": 5,