@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 +1 -1
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +40 -8
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +251 -31
- package/uscha-kit/.claude-plugin/plugin.json +1 -1
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +75 -8
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/install-uscha.py +119 -14
- package/uscha-kit/skills/uscha-devloop/SKILL.md +40 -8
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +251 -31
- package/uscha-kit/templates/CONSTITUTION.md +10 -3
- package/uscha-kit/uscha.config.json +4 -2
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
|
|
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.
|
|
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
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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.
|
|
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
|
|
2703
|
-
pass
|
|
2704
|
-
|
|
2705
|
-
|
|
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
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
10403
|
-
f"aplanar: guard clauses / extraer función
|
|
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
|
|
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, "
|
|
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, "
|
|
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(
|
|
10609
|
+
sys.exit(exit_code)
|
|
10457
10610
|
|
|
10458
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
12340
|
-
"
|
|
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,
|
|
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.
|
|
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": {
|