@andresmassello/uscha 1.99.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,7 +40,7 @@ Requires **Python 3.8+** on the machine (the engine is Python stdlib — no pip
40
40
  runtime dependencies). The npm package is a thin router; the canonical installer is
41
41
  `uscha-kit/install-uscha.py`.
42
42
 
43
- **Kit v1.99.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
43
+ **Kit v2.0.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
44
44
  [changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG.md)
45
45
  (the per-release changelogs live in the repo, not in the npm tarball)
46
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andresmassello/uscha",
3
- "version": "1.99.0",
3
+ "version": "2.0.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",
@@ -314,7 +314,16 @@ profile A (trivial change) skip it. It **collapses into `readiness`** as a `gate
314
314
  ## Phase 3 — QA loop (per repo)
315
315
 
316
316
  Run the tools in `config.defaults.qa_tools_order` (default: code-review → judgment-day
317
- → improve). One pass of all tools = one cycle. After **each** tool pass:
317
+ → improve). One pass of all tools = one cycle.
318
+
319
+ Read the EFFECTIVE order, not the config file: since 2.0.0 `uscha init` generates a minimal
320
+ config, so `qa_tools_order` is often absent there and resolves from `defaults.risk_profile`
321
+ (ADR-001) or from the engine default. `qa_ledger.py doctor --json` prints it as
322
+ `effective.qa_tools_order` with its `origin`; the ledger froze the same value at `init`. A
323
+ project on **profile A runs `code-review` only** — do NOT invoke judgment-day or improve there,
324
+ and convergence must not wait for them.
325
+
326
+ After **each** tool pass:
318
327
 
319
328
  1. Apply only fixes at/above the severity gate. Send the rest to `ISSUES-DEFERRED.md`.
320
329
  2. Run the repo test command. If red and the fix isn't obvious → escalate.
@@ -1911,6 +1911,53 @@ def _apply_risk_profile(defaults):
1911
1911
  return defaults
1912
1912
 
1913
1913
 
1914
+ # The engine's OWN value for each knob a risk profile owns (kit 2.0.0): the bottom rung of
1915
+ # the precedence ladder -- explicit override > selected profile > engine default -- as `doctor`
1916
+ # reports it, so the origin of every effective value is visible without reading the source.
1917
+ #
1918
+ # It is a REPORTING table and is deliberately NEVER materialized into `defaults`. Writing an
1919
+ # engine default into the config would make the kit's own value indistinguishable from a human
1920
+ # declaration, which is precisely what `thresholds_declared` / `declared_caps` exist to keep
1921
+ # apart (provenance, kit 1.17.0) -- and precisely the confusion that let a COPIED config
1922
+ # outrank the profile until 2.0.0. The fix for that was to stop copying, not to start
1923
+ # injecting: `uscha init` now generates a minimal config (install-uscha.py), and a knob it
1924
+ # leaves out keeps meaning "not declared" everywhere the engine reads it.
1925
+ #
1926
+ # qa_tools_order is None on purpose: with no list declared, convergence falls back to a window
1927
+ # of --tools-per-cycle agent steps, so there is no default list to name and saying otherwise
1928
+ # would be a narrated claim.
1929
+ ENGINE_DEFAULTS = {
1930
+ "qa_tools_order": None,
1931
+ "coverage_threshold": 60,
1932
+ "golden_required": False,
1933
+ }
1934
+ ENGINE_DEFAULT_NOTES = {
1935
+ "qa_tools_order": "not declared - convergence uses a window of --tools-per-cycle "
1936
+ "agent steps",
1937
+ }
1938
+
1939
+
1940
+ def _resolved_defaults(cfg):
1941
+ """(resolved defaults, origin per profile-owned key) for a RAW config -- the effective
1942
+ settings `doctor` reports. Origin is `override` (declared in defaults), `profile <X>`, or
1943
+ `default`. Read-only: never mutates cfg and never writes anything back."""
1944
+ raw = cfg.get("defaults") if isinstance(cfg, dict) else None
1945
+ raw = dict(raw) if isinstance(raw, dict) else {}
1946
+ declared = set(raw)
1947
+ profile = raw.get("risk_profile")
1948
+ expanded = _apply_risk_profile(dict(raw))
1949
+ resolved, origin = {}, {}
1950
+ for key, fallback in ENGINE_DEFAULTS.items():
1951
+ resolved[key] = expanded[key] if key in expanded else fallback
1952
+ if key in declared:
1953
+ origin[key] = "override"
1954
+ elif profile and key in RISK_PROFILES.get(profile, {}):
1955
+ origin[key] = "profile %s" % profile
1956
+ else:
1957
+ origin[key] = "default"
1958
+ return resolved, origin
1959
+
1960
+
1914
1961
  def _validate_init_config(cfg):
1915
1962
  """Validate only the engine's core init contract before creating a ledger."""
1916
1963
  if not isinstance(cfg, dict):
@@ -1921,6 +1968,9 @@ def _validate_init_config(cfg):
1921
1968
  # expand a named risk profile into concrete knobs BEFORE validating them, so the merged
1922
1969
  # values (qa_tools_order, coverage_threshold, golden_required) flow through the checks
1923
1970
  # below. Explicit config wins per key; an unknown profile fails loud (INV-RISK-01).
1971
+ # Nothing else is written into `defaults`: a key the human did not declare and the profile
1972
+ # did not supply stays ABSENT, so provenance can still tell the kit's default apart from a
1973
+ # declaration (1.17.0). ENGINE_DEFAULTS is what `doctor` reports, never what init freezes.
1924
1974
  _apply_risk_profile(defaults)
1925
1975
  if "golden_required" in defaults and not isinstance(defaults["golden_required"], bool):
1926
1976
  raise SystemExit("[qa_ledger] invalid config: golden_required must be a boolean")
@@ -12273,6 +12323,7 @@ def cmd_doctor(args):
12273
12323
 
12274
12324
  # --- proyecto (si hay config aca) ---------------------------------------
12275
12325
  qa_order = ["code-review", "judgment-day", "improve"] # default del kit
12326
+ effective, risk_profile = None, None
12276
12327
  cfg_path = args.config or "uscha.config.json"
12277
12328
  if os.path.isfile(cfg_path):
12278
12329
  try:
@@ -12296,7 +12347,50 @@ def cmd_doctor(args):
12296
12347
  warn(f"ACCEPTANCE {acc} has no criteria (zero checkboxes)")
12297
12348
  elif acc:
12298
12349
  warn(f"acceptance_file declared but missing: {acc}")
12299
- qa_order = defaults.get("qa_tools_order", qa_order)
12350
+ # effective settings, with the ORIGIN of each (2.0.0): the three-rung ladder --
12351
+ # explicit override > selected profile > engine default. Reported, never written
12352
+ # back. An override that supersedes the profile is reported as INFORMATION:
12353
+ # declaring a knob by hand is the documented way to bend a preset, never an error.
12354
+ #
12355
+ # An UNKNOWN profile is caught HERE rather than by the config-wide handler below.
12356
+ # `_apply_risk_profile` raises SystemExit on it (INV-RISK-01: a declared risk level
12357
+ # is never inert, and `init` refuses such a config) -- letting that escape would
12358
+ # abandon the toolchain, rubric and ledger checks that follow, so the diagnostic
12359
+ # would go blind on the first bad key instead of naming it. It is a WARN because
12360
+ # that is the verdict doctor gave this config before 2.0.0: resolving the profile
12361
+ # is a new REPORT, and a new report may not silently raise an existing exit code.
12362
+ risk_profile = defaults.get("risk_profile")
12363
+ try:
12364
+ resolved, origin = _resolved_defaults(cfg)
12365
+ except SystemExit:
12366
+ resolved, origin = None, None
12367
+ warn("unknown risk profile %s - no preset applied" % ascii(risk_profile),
12368
+ "valid: " + ", ".join(sorted(RISK_PROFILES))
12369
+ + " - `init` refuses this config (INV-RISK-01); fix it before the loop "
12370
+ "runs. Every other check below still ran.")
12371
+ if origin is not None:
12372
+ effective = {k: {"value": resolved.get(k), "origin": origin[k]}
12373
+ for k in ENGINE_DEFAULTS}
12374
+ ok("risk profile: %s" % (risk_profile or "none declared"),
12375
+ "effective settings below - precedence: override > profile > default")
12376
+ for key in ENGINE_DEFAULTS:
12377
+ value = resolved.get(key)
12378
+ if isinstance(value, list):
12379
+ shown = ", ".join(value)
12380
+ elif value is None:
12381
+ shown = ENGINE_DEFAULT_NOTES.get(key, "not declared")
12382
+ else:
12383
+ shown = value
12384
+ detail = "origin: " + origin[key]
12385
+ if (origin[key] == "override" and risk_profile
12386
+ and key in RISK_PROFILES.get(risk_profile, {})):
12387
+ detail += (" - this override supersedes profile %s (information: an "
12388
+ "explicit declaration is how a preset is bent)"
12389
+ % risk_profile)
12390
+ ok("effective %s = %s" % (key, shown), detail)
12391
+ # the QA-skills check below asks for the tools actually in force; with no list
12392
+ # declared it keeps looking for the kit's three, as it always has
12393
+ qa_order = resolved.get("qa_tools_order") or qa_order
12300
12394
  for r in repos:
12301
12395
  tool = DOCTOR_TOOLS.get(r.get("type", ""))
12302
12396
  if not tool:
@@ -12336,8 +12430,11 @@ def cmd_doctor(args):
12336
12430
  err(f"{cfg_path} invalid", str(exc))
12337
12431
  else:
12338
12432
  warn(f"no {cfg_path} in this directory",
12339
- "install: copy the kit uscha.config.json to the repo root and declare "
12340
- "your repos/types and your quality bar - only needed to RUN the loop here")
12433
+ "install: run `uscha init` here (or `python install-uscha.py init --repo .`) - it "
12434
+ "GENERATES a minimal one and detects the repo; then declare your quality bar. Do "
12435
+ "NOT copy the kit's uscha.config.json: it is a REFERENCE, and every knob in it "
12436
+ "would arrive as an explicit declaration outranking your risk_profile (ADR-001, "
12437
+ "as amended) - only needed to RUN the loop here")
12341
12438
 
12342
12439
  # --- skills de QA del loop (externas al kit, se orquestan sin traerlas) --
12343
12440
  # sin ellas la fase 3 (QA loop) no corre; chequeables con o sin config.
@@ -12361,6 +12458,9 @@ def cmd_doctor(args):
12361
12458
  print(json.dumps({"verdict": "ERROR" if n_err else ("WARN" if n_warn else "OK"),
12362
12459
  "ok": n_ok, "warnings": n_warn, "errors": n_err,
12363
12460
  "global_install": is_global, "plugin_install": is_plugin,
12461
+ # effective settings + origin per knob (2.0.0); null when there is
12462
+ # no project config here to resolve them from
12463
+ "risk_profile": risk_profile, "effective": effective,
12364
12464
  "checks": [{"level": lv, "title": t, "detail": d}
12365
12465
  for lv, t, d in checks]},
12366
12466
  indent=2, ensure_ascii=True))
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "uscha",
4
- "version": "1.99.0",
4
+ "version": "2.0.0",
5
5
  "displayName": "Uscha",
6
6
  "description": "Spec-driven development for LLM coding agents: 9 skills (discovery, adr-refine, reverse-discovery, characterize, devloop, sysdoc, rubric, mirador, status) + a stdlib measurement engine (qa_ledger.py, 53 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
7
7
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uscha",
3
- "version": "1.99.0",
3
+ "version": "2.0.0",
4
4
  "description": "Uscha spec-driven development methodology for coding agents. Includes npm/npx router.",
5
5
  "author": {
6
6
  "name": "Andres Massello",
@@ -1,6 +1,6 @@
1
1
  # uscha-kit
2
2
 
3
- **Kit version:** v1.99.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
3
+ **Kit version:** v2.0.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`,
@@ -444,7 +444,60 @@ primary toolchain of each repo by type (its absence is a WARNING — it may live
444
444
 
445
445
  ## Configure
446
446
 
447
- Edit `uscha.config.json`:
447
+ `uscha init` **generates** a minimal `uscha.config.json` for the project: its name, the repo it
448
+ detected (path, type, test command), `acceptance_file`, `id_granularity`, `max_iterations`, and
449
+ the `fast_path` block and the `integration` switch (no contract command: that names a build
450
+ system and is yours to add). Those last two are there because the engine defaults
451
+ both to OFF and the kit means them ON — without them `fastpath-eval` would answer
452
+ `DENY configured: false` on a fresh project and readiness would measure five dimensions instead
453
+ of six. Every other knob is absent on purpose and resolves to the engine default. The kit's own
454
+ `uscha-kit/uscha.config.json` is the **comprehensive reference** — every knob at the kit's
455
+ value, to read and copy from — and is no longer copied into projects.
456
+
457
+ That matters because of one rule: **a knob you declare > the preset named by
458
+ `defaults.risk_profile` > the engine default**. A copied default is an explicit declaration, so
459
+ a project holding the whole reference leaves its risk profile nothing to decide.
460
+
461
+ - `defaults.risk_profile`: `A`..`E` (ADR-001) — a named preset that expands into
462
+ `qa_tools_order`, `coverage_threshold` and `golden_required`. `A` (trivial) runs
463
+ `[code-review]` only; `E` (migration/legacy) requires the three tools, coverage 80 and an
464
+ approved golden. Absent = the kit defaults.
465
+ - Check what is actually in force, and where each value came from:
466
+
467
+ ```bash
468
+ python3 ~/.claude/skills/uscha-devloop/qa_ledger.py doctor --json
469
+ # -> "risk_profile" and "effective": {knob: {value, origin}}
470
+ ```
471
+
472
+ `origin` is `override` (you declared it), `profile <X>` (the preset supplied it), or
473
+ `default` (the engine's own value). An override that supersedes a profile is reported as
474
+ information, not an error — declaring a knob by hand is how a preset is bent. Nothing is
475
+ written back into your config: a knob nobody declared stays absent, which is what lets the
476
+ engine keep telling a requirement apart from a default. With no profile and no
477
+ `qa_tools_order`, that knob reads `not declared` and convergence uses a window of
478
+ `--tools-per-cycle` agent steps.
479
+
480
+ **Migrating a project initialised before 2.0.0.** Its config is the full copy, so
481
+ `coverage_threshold` and `qa_tools_order` read `origin: override` and no profile can move them.
482
+ (`golden_required` is the one the reference never declared, which is why a profile could still
483
+ supply it — that is the whole shape of the defect: the preset reached only the keys the copy
484
+ happened to omit.) Delete the ones you never meant to declare, then add the profile:
485
+
486
+ ```diff
487
+ {
488
+ "defaults": {
489
+ - "coverage_threshold": 60,
490
+ - "qa_tools_order": ["code-review", "judgment-day", "improve"],
491
+ + "risk_profile": "A",
492
+ "acceptance_file": "ACCEPTANCE.md"
493
+ }
494
+ }
495
+ ```
496
+
497
+ Nothing is deleted for you: a value equal to a former default cannot be told apart from a value
498
+ you chose, so existing configs keep behaving exactly as they did.
499
+
500
+ The full set of knobs, all optional:
448
501
 
449
502
  - `repos[]`: name, `path` (relative to the primary repo), `type` (`maven`|`flutter`|`python`|`node`|`go`|`rust`|`dotnet`|`cpp`|`gradle`|`swift`).
450
503
  - `defaults.coverage_threshold`: triggers the characterization phase if below it.
package/uscha-kit/VERSION CHANGED
@@ -1 +1 @@
1
- uscha-kit 1.99.0
1
+ uscha-kit 2.0.0
@@ -693,39 +693,144 @@ def _wire_statusline_settings(repo, force, dry_run):
693
693
  return ops, wrote, conflicts
694
694
 
695
695
 
696
+ # The one marker file that names a repo's toolchain, most specific first: a flutter repo also
697
+ # has a pubspec, a gradle repo has no pom, and a python repo can carry a package.json for its
698
+ # tooling. `.csproj`/`.sln` need a glob, so dotnet is probed separately.
699
+ REPO_TYPE_MARKERS = (
700
+ ("pubspec.yaml", "flutter"), ("pom.xml", "maven"), ("build.gradle", "gradle"),
701
+ ("build.gradle.kts", "gradle"), ("go.mod", "go"), ("Cargo.toml", "rust"),
702
+ ("Package.swift", "swift"), ("pyproject.toml", "python"), ("setup.py", "python"),
703
+ ("CMakeLists.txt", "cpp"), ("package.json", "node"),
704
+ )
705
+
706
+ GENERATED_CONFIG_NOTE = (
707
+ "Minimal project config, generated by `uscha init`. Every knob NOT declared here resolves "
708
+ "to the engine default -- the kit's own uscha.config.json is the comprehensive reference "
709
+ "for what can be declared. Precedence is: a knob you write here > the preset named by "
710
+ "defaults.risk_profile (A..E, ADR-001) > the engine default. That is why this file is "
711
+ "minimal: a copied default is an explicit declaration, and an explicit declaration "
712
+ "outranks the preset you selected. The blocks that ARE here are the ones the kit means to "
713
+ "be on and the engine defaults to off (fast_path, integration) plus what the engine cannot "
714
+ "derive -- never a knob a risk profile owns."
715
+ )
716
+
717
+
718
+ def detect_repo_type(repo):
719
+ """The repo's toolchain, read off the one marker file each build system leaves at its root.
720
+ None when nothing is recognised -- the human declares `repos[]` by hand, and `init` says so
721
+ instead of guessing. Never raises: a missing directory (`--dry-run` on a path that does not
722
+ exist yet) simply has no markers."""
723
+ if not repo.is_dir():
724
+ return None
725
+ for marker, repo_type in REPO_TYPE_MARKERS:
726
+ if (repo / marker).is_file():
727
+ return repo_type
728
+ return "dotnet" if any(repo.glob("*.csproj")) or any(repo.glob("*.sln")) else None
729
+
730
+
731
+ def _kit_value(block, key, prefix=""):
732
+ """One value the generated config copies from the kit reference. A missing key means the
733
+ kit's own uscha.config.json has been edited into something `init` cannot generate from --
734
+ a NAMED refusal, never a raw KeyError traceback out of the installer. `prefix` is only for
735
+ that message, so it names the path the human has to go and look at."""
736
+ if not isinstance(block, dict) or key not in block:
737
+ raise InstallError("[install-uscha] kit uscha.config.json has no %s%s - cannot "
738
+ "generate a project config from it" % (prefix, key))
739
+ return block[key]
740
+
741
+
742
+ def project_config_bytes(repo):
743
+ """The project's uscha.config.json, GENERATED rather than copied (kit 2.0.0).
744
+
745
+ Copying the kit's reference config turned every kit default into an explicit project
746
+ declaration, and an explicit declaration outranks the selected `risk_profile` by design
747
+ (ADR-001) -- so `risk_profile: "A"` still demanded judgment-day and improve, and `"E"`
748
+ still measured coverage against 60. The presets had never taken effect in a repo
749
+ initialised with `init`. What is written here is only what the engine cannot derive:
750
+ identity, the repos, the knobs whose kit intent differs from the engine default, and the
751
+ ones the SKILL reads (the engine has no fallback for those). Everything else is absent on
752
+ purpose, so the preset -- once the human declares one -- has something left to decide.
753
+
754
+ "Minimal" is not "empty", and the line is drawn at INTENT, never at size: a knob the kit
755
+ means to be on and the engine defaults to off must be written, or generating instead of
756
+ copying would silently turn features off. `fast_path` (engine: absent = fail-closed, so
757
+ `fastpath-eval` would answer DENY `configured: false` on every fresh project) and
758
+ `integration` (engine: disabled, so readiness would measure five dimensions instead of
759
+ six) are exactly that. `fast_path` is copied as a WHOLE block from the reference;
760
+ `integration` carries only its switch, because its contract command names a build system
761
+ and guessing one is what this generator exists to refuse. No knob any risk
762
+ profile owns is written here -- that is the defect this release exists to fix, and
763
+ `AC-RP-06` measures it against the engine's own table."""
764
+ kit_cfg = load_json(KIT_ROOT / "uscha.config.json", "kit uscha.config.json")
765
+ kit_defaults = kit_cfg.get("defaults")
766
+ defaults = {
767
+ # engine default is None: without it readiness has no acceptance list to measure
768
+ "acceptance_file": _kit_value(kit_defaults, "acceptance_file", "defaults."),
769
+ # engine default is "line"; the kit's chosen value is "file" (stable under refactors)
770
+ "id_granularity": _kit_value(kit_defaults, "id_granularity", "defaults."),
771
+ # read by the uscha-devloop SKILL, never by the engine, so it has no fallback
772
+ "max_iterations": _kit_value(kit_defaults, "max_iterations", "defaults."),
773
+ }
774
+ repos = []
775
+ repo_type = detect_repo_type(repo)
776
+ if repo_type:
777
+ key = "test_command_" + repo_type
778
+ defaults[key] = _kit_value(kit_defaults, key, "defaults.")
779
+ repos.append({"name": repo.name, "path": ".", "type": repo_type})
780
+ # engine default: absent means NOT configured, and fastpath-eval fails closed on it
781
+ defaults["fast_path"] = _kit_value(kit_defaults, "fast_path", "defaults.")
782
+ config = {"_comment": GENERATED_CONFIG_NOTE, "version": source_version(),
783
+ "project": repo.name, "defaults": defaults, "repos": repos,
784
+ # top-level, not a default: engine default is disabled, which drops the
785
+ # integration dimension out of readiness entirely
786
+ # integration dimension out of readiness entirely. Only the switch: the
787
+ # contract command is a build-system guess the kit refuses to make
788
+ "integration": {"enabled": True}}
789
+ # byte-identical across runs on the same repo + kit, so a second `init` reports `unchanged`
790
+ # instead of a conflict; LF explicitly, like every other artifact the kit writes.
791
+ return (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
792
+
793
+
696
794
  def cmd_init(args):
697
795
  repo, operations, conflicts = Path(args.repo).expanduser().resolve(), [], []
698
- sources = ([(KIT_ROOT / "uscha.config.json", repo / "uscha.config.json")]
699
- + [(KIT_ROOT / "templates" / name, repo / name)
796
+ # (source path or None, payload bytes or None, target): the config is generated, the rest
797
+ # are copied from templates/. Exactly one of the first two is set per entry.
798
+ sources = ([(None, project_config_bytes(repo), repo / "uscha.config.json")]
799
+ + [(KIT_ROOT / "templates" / name, None, repo / name)
700
800
  # AGENTS.md (kit 1.50.2): the context file Codex/pi read (they do not read
701
801
  # CLAUDE.md); shipped as a thin pointer to CLAUDE.md so there is ONE source.
702
802
  for name in ("CLAUDE.md", "AGENTS.md", "CONSTITUTION.md", ".gitattributes")]
703
- + [(KIT_ROOT / "templates" / "scripts" / s, repo / ".claude" / "scripts" / s)
803
+ + [(KIT_ROOT / "templates" / "scripts" / s, None, repo / ".claude" / "scripts" / s)
704
804
  for s in STATUSLINE_SCRIPTS])
705
805
  copies = []
706
- for source, target in sources:
707
- if not source.is_file():
806
+ for source, payload, target in sources:
807
+ if source is not None and not source.is_file():
708
808
  raise InstallError("[install-uscha] init source missing: %s" % source)
709
809
  if target.is_symlink():
710
810
  raise InstallError("[install-uscha] init target must not be a symlink: %s" % target)
711
811
  if target.exists() and not target.is_file():
712
812
  raise InstallError("[install-uscha] init target must be a file: %s" % target)
713
- if target.exists() and target.read_bytes() != source.read_bytes() and not args.force:
714
- conflicts.append({"path": str(target), "source": str(source)})
715
- operations.append({"action": "conflict", "path": str(target), "source": str(source)})
716
- elif target.exists() and target.read_bytes() == source.read_bytes():
813
+ want = payload if payload is not None else source.read_bytes()
814
+ label = str(source) if source is not None else "<generated>"
815
+ if target.exists() and target.read_bytes() != want and not args.force:
816
+ conflicts.append({"path": str(target), "source": label})
817
+ operations.append({"action": "conflict", "path": str(target), "source": label})
818
+ elif target.exists() and target.read_bytes() == want:
717
819
  operations.append({"action": "unchanged", "path": str(target)})
718
820
  else:
719
- operations.append({"action": "copy-file", "path": str(target), "source": str(source), "note": "force" if target.exists() else None})
720
- copies.append((source, target))
821
+ operations.append({"action": "copy-file", "path": str(target), "source": label, "note": "force" if target.exists() else None})
822
+ copies.append((source, want, target))
721
823
  # per-file, not all-or-nothing (kit 1.44.1): a differing CLAUDE.md (which EVERY repo
722
824
  # already using Claude Code has) used to block ALL four copies. Now the non-conflicting
723
825
  # files are written regardless; each conflict is reported and left untouched (resolve by
724
826
  # hand, or re-run with --force). Exit stays nonzero while any conflict remains.
725
827
  if not args.dry_run:
726
- for source, target in copies:
828
+ for source, want, target in copies:
727
829
  target.parent.mkdir(parents=True, exist_ok=True)
728
- shutil.copy2(source, target)
830
+ if source is None:
831
+ target.write_bytes(want)
832
+ else:
833
+ shutil.copy2(source, target)
729
834
  # wire the statusline (kit 1.46.0): merge statusLine + Stop hook into the project's
730
835
  # settings.json so the user never edits it by hand -- never clobbering existing keys.
731
836
  sl_ops, sl_wrote, sl_conflicts = _wire_statusline_settings(repo, args.force, args.dry_run)
@@ -736,7 +841,7 @@ def cmd_init(args):
736
841
  status = "conflicts" if args.dry_run else "partial"
737
842
  else:
738
843
  status = "planned" if args.dry_run else "initialized"
739
- wrote = [str(t) for _, t in copies] if not args.dry_run else []
844
+ wrote = [str(t) for _, _, t in copies] if not args.dry_run else []
740
845
  if sl_wrote:
741
846
  wrote.append(str(repo / ".claude" / "settings.json"))
742
847
  emit({"status": status, "dry_run": args.dry_run, "repo": str(repo),
@@ -314,7 +314,16 @@ profile A (trivial change) skip it. It **collapses into `readiness`** as a `gate
314
314
  ## Phase 3 — QA loop (per repo)
315
315
 
316
316
  Run the tools in `config.defaults.qa_tools_order` (default: code-review → judgment-day
317
- → improve). One pass of all tools = one cycle. After **each** tool pass:
317
+ → improve). One pass of all tools = one cycle.
318
+
319
+ Read the EFFECTIVE order, not the config file: since 2.0.0 `uscha init` generates a minimal
320
+ config, so `qa_tools_order` is often absent there and resolves from `defaults.risk_profile`
321
+ (ADR-001) or from the engine default. `qa_ledger.py doctor --json` prints it as
322
+ `effective.qa_tools_order` with its `origin`; the ledger froze the same value at `init`. A
323
+ project on **profile A runs `code-review` only** — do NOT invoke judgment-day or improve there,
324
+ and convergence must not wait for them.
325
+
326
+ After **each** tool pass:
318
327
 
319
328
  1. Apply only fixes at/above the severity gate. Send the rest to `ISSUES-DEFERRED.md`.
320
329
  2. Run the repo test command. If red and the fix isn't obvious → escalate.
@@ -1911,6 +1911,53 @@ def _apply_risk_profile(defaults):
1911
1911
  return defaults
1912
1912
 
1913
1913
 
1914
+ # The engine's OWN value for each knob a risk profile owns (kit 2.0.0): the bottom rung of
1915
+ # the precedence ladder -- explicit override > selected profile > engine default -- as `doctor`
1916
+ # reports it, so the origin of every effective value is visible without reading the source.
1917
+ #
1918
+ # It is a REPORTING table and is deliberately NEVER materialized into `defaults`. Writing an
1919
+ # engine default into the config would make the kit's own value indistinguishable from a human
1920
+ # declaration, which is precisely what `thresholds_declared` / `declared_caps` exist to keep
1921
+ # apart (provenance, kit 1.17.0) -- and precisely the confusion that let a COPIED config
1922
+ # outrank the profile until 2.0.0. The fix for that was to stop copying, not to start
1923
+ # injecting: `uscha init` now generates a minimal config (install-uscha.py), and a knob it
1924
+ # leaves out keeps meaning "not declared" everywhere the engine reads it.
1925
+ #
1926
+ # qa_tools_order is None on purpose: with no list declared, convergence falls back to a window
1927
+ # of --tools-per-cycle agent steps, so there is no default list to name and saying otherwise
1928
+ # would be a narrated claim.
1929
+ ENGINE_DEFAULTS = {
1930
+ "qa_tools_order": None,
1931
+ "coverage_threshold": 60,
1932
+ "golden_required": False,
1933
+ }
1934
+ ENGINE_DEFAULT_NOTES = {
1935
+ "qa_tools_order": "not declared - convergence uses a window of --tools-per-cycle "
1936
+ "agent steps",
1937
+ }
1938
+
1939
+
1940
+ def _resolved_defaults(cfg):
1941
+ """(resolved defaults, origin per profile-owned key) for a RAW config -- the effective
1942
+ settings `doctor` reports. Origin is `override` (declared in defaults), `profile <X>`, or
1943
+ `default`. Read-only: never mutates cfg and never writes anything back."""
1944
+ raw = cfg.get("defaults") if isinstance(cfg, dict) else None
1945
+ raw = dict(raw) if isinstance(raw, dict) else {}
1946
+ declared = set(raw)
1947
+ profile = raw.get("risk_profile")
1948
+ expanded = _apply_risk_profile(dict(raw))
1949
+ resolved, origin = {}, {}
1950
+ for key, fallback in ENGINE_DEFAULTS.items():
1951
+ resolved[key] = expanded[key] if key in expanded else fallback
1952
+ if key in declared:
1953
+ origin[key] = "override"
1954
+ elif profile and key in RISK_PROFILES.get(profile, {}):
1955
+ origin[key] = "profile %s" % profile
1956
+ else:
1957
+ origin[key] = "default"
1958
+ return resolved, origin
1959
+
1960
+
1914
1961
  def _validate_init_config(cfg):
1915
1962
  """Validate only the engine's core init contract before creating a ledger."""
1916
1963
  if not isinstance(cfg, dict):
@@ -1921,6 +1968,9 @@ def _validate_init_config(cfg):
1921
1968
  # expand a named risk profile into concrete knobs BEFORE validating them, so the merged
1922
1969
  # values (qa_tools_order, coverage_threshold, golden_required) flow through the checks
1923
1970
  # below. Explicit config wins per key; an unknown profile fails loud (INV-RISK-01).
1971
+ # Nothing else is written into `defaults`: a key the human did not declare and the profile
1972
+ # did not supply stays ABSENT, so provenance can still tell the kit's default apart from a
1973
+ # declaration (1.17.0). ENGINE_DEFAULTS is what `doctor` reports, never what init freezes.
1924
1974
  _apply_risk_profile(defaults)
1925
1975
  if "golden_required" in defaults and not isinstance(defaults["golden_required"], bool):
1926
1976
  raise SystemExit("[qa_ledger] invalid config: golden_required must be a boolean")
@@ -12273,6 +12323,7 @@ def cmd_doctor(args):
12273
12323
 
12274
12324
  # --- proyecto (si hay config aca) ---------------------------------------
12275
12325
  qa_order = ["code-review", "judgment-day", "improve"] # default del kit
12326
+ effective, risk_profile = None, None
12276
12327
  cfg_path = args.config or "uscha.config.json"
12277
12328
  if os.path.isfile(cfg_path):
12278
12329
  try:
@@ -12296,7 +12347,50 @@ def cmd_doctor(args):
12296
12347
  warn(f"ACCEPTANCE {acc} has no criteria (zero checkboxes)")
12297
12348
  elif acc:
12298
12349
  warn(f"acceptance_file declared but missing: {acc}")
12299
- qa_order = defaults.get("qa_tools_order", qa_order)
12350
+ # effective settings, with the ORIGIN of each (2.0.0): the three-rung ladder --
12351
+ # explicit override > selected profile > engine default. Reported, never written
12352
+ # back. An override that supersedes the profile is reported as INFORMATION:
12353
+ # declaring a knob by hand is the documented way to bend a preset, never an error.
12354
+ #
12355
+ # An UNKNOWN profile is caught HERE rather than by the config-wide handler below.
12356
+ # `_apply_risk_profile` raises SystemExit on it (INV-RISK-01: a declared risk level
12357
+ # is never inert, and `init` refuses such a config) -- letting that escape would
12358
+ # abandon the toolchain, rubric and ledger checks that follow, so the diagnostic
12359
+ # would go blind on the first bad key instead of naming it. It is a WARN because
12360
+ # that is the verdict doctor gave this config before 2.0.0: resolving the profile
12361
+ # is a new REPORT, and a new report may not silently raise an existing exit code.
12362
+ risk_profile = defaults.get("risk_profile")
12363
+ try:
12364
+ resolved, origin = _resolved_defaults(cfg)
12365
+ except SystemExit:
12366
+ resolved, origin = None, None
12367
+ warn("unknown risk profile %s - no preset applied" % ascii(risk_profile),
12368
+ "valid: " + ", ".join(sorted(RISK_PROFILES))
12369
+ + " - `init` refuses this config (INV-RISK-01); fix it before the loop "
12370
+ "runs. Every other check below still ran.")
12371
+ if origin is not None:
12372
+ effective = {k: {"value": resolved.get(k), "origin": origin[k]}
12373
+ for k in ENGINE_DEFAULTS}
12374
+ ok("risk profile: %s" % (risk_profile or "none declared"),
12375
+ "effective settings below - precedence: override > profile > default")
12376
+ for key in ENGINE_DEFAULTS:
12377
+ value = resolved.get(key)
12378
+ if isinstance(value, list):
12379
+ shown = ", ".join(value)
12380
+ elif value is None:
12381
+ shown = ENGINE_DEFAULT_NOTES.get(key, "not declared")
12382
+ else:
12383
+ shown = value
12384
+ detail = "origin: " + origin[key]
12385
+ if (origin[key] == "override" and risk_profile
12386
+ and key in RISK_PROFILES.get(risk_profile, {})):
12387
+ detail += (" - this override supersedes profile %s (information: an "
12388
+ "explicit declaration is how a preset is bent)"
12389
+ % risk_profile)
12390
+ ok("effective %s = %s" % (key, shown), detail)
12391
+ # the QA-skills check below asks for the tools actually in force; with no list
12392
+ # declared it keeps looking for the kit's three, as it always has
12393
+ qa_order = resolved.get("qa_tools_order") or qa_order
12300
12394
  for r in repos:
12301
12395
  tool = DOCTOR_TOOLS.get(r.get("type", ""))
12302
12396
  if not tool:
@@ -12336,8 +12430,11 @@ def cmd_doctor(args):
12336
12430
  err(f"{cfg_path} invalid", str(exc))
12337
12431
  else:
12338
12432
  warn(f"no {cfg_path} in this directory",
12339
- "install: copy the kit uscha.config.json to the repo root and declare "
12340
- "your repos/types and your quality bar - only needed to RUN the loop here")
12433
+ "install: run `uscha init` here (or `python install-uscha.py init --repo .`) - it "
12434
+ "GENERATES a minimal one and detects the repo; then declare your quality bar. Do "
12435
+ "NOT copy the kit's uscha.config.json: it is a REFERENCE, and every knob in it "
12436
+ "would arrive as an explicit declaration outranking your risk_profile (ADR-001, "
12437
+ "as amended) - only needed to RUN the loop here")
12341
12438
 
12342
12439
  # --- skills de QA del loop (externas al kit, se orquestan sin traerlas) --
12343
12440
  # sin ellas la fase 3 (QA loop) no corre; chequeables con o sin config.
@@ -12361,6 +12458,9 @@ def cmd_doctor(args):
12361
12458
  print(json.dumps({"verdict": "ERROR" if n_err else ("WARN" if n_warn else "OK"),
12362
12459
  "ok": n_ok, "warnings": n_warn, "errors": n_err,
12363
12460
  "global_install": is_global, "plugin_install": is_plugin,
12461
+ # effective settings + origin per knob (2.0.0); null when there is
12462
+ # no project config here to resolve them from
12463
+ "risk_profile": risk_profile, "effective": effective,
12364
12464
  "checks": [{"level": lv, "title": t, "detail": d}
12365
12465
  for lv, t, d in checks]},
12366
12466
  indent=2, ensure_ascii=True))
@@ -1,5 +1,6 @@
1
1
  {
2
- "version": "1.99.0",
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
4
  "project": null,
4
5
  "defaults": {
5
6
  "coverage_threshold": 60,