@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.
@@ -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.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`,
@@ -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.
@@ -568,9 +621,9 @@ python3 $QL rebuild --mode compare --baseline REBUILD-BASELINE.json --json # c
568
621
  - Verdicts: `COVERS ≥90` · `PARTIAL ≥70` · `DIVERGE <70`. The score lists the concrete
569
622
  **gaps** — feed them back into the SPEC and re-run. Divergence is a spec hole, not a code bug.
570
623
 
571
- ## Simplicity gate — "Reduce" (minimality of the change)
624
+ ## Simplicity check — "Reduce" (minimality of the change) — ADVISORY by default
572
625
 
573
- 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*
574
627
  (not CC by AST — they are measurable proxies: minimality, nesting, new abstractions).
575
628
 
576
629
  ```bash
@@ -581,21 +634,35 @@ python3 $QL simplicity-check --diff changes.diff --json # consumed by usc
581
634
 
582
635
  - Dimensions/weights: diff_size 35, nesting 30, net_growth 20, fan_out 8, blob 7
583
636
  (abstraction does NOT weigh in the score — it's a guessy proxy, kept as a metric + advisory flag).
584
- - Verdicts: `SIMPLE ≥85` · `ACCEPTABLE ≥65` · `OVERBUILT <65` (exit 1 = BLOCKER: trim and re-run).
637
+ - Verdicts: `SIMPLE ≥85` · `ACCEPTABLE ≥65` · `OVERBUILT <65`.
585
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`.
586
650
  - **Tests OUT of the budget** (kit 1.11.0): the test files (conventions of the
587
651
  9 stacks) are counted and reported separately (`test_lines_added`) but do not gate — writing
588
652
  tests never pushes the diff to OVERBUILT (deleting them is already blocked by gate-check).
589
653
  - The flags tell you what to trim (guard clauses, speculative types/layers, giant hunks).
590
- - 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.
591
656
 
592
657
  ## Ledger subcommands
593
658
 
594
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`.
595
660
 
596
661
  The **fact gates** (golden-diff, gate-check, pit-check, simplicity) are PERSISTED with
597
- `log-gate`: a fail blocks convergence and caps readiness ≤65 via the ledger. A CONSTITUTION
598
- 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`).
599
666
 
600
667
  ## Notes
601
668
 
package/uscha-kit/VERSION CHANGED
@@ -1 +1 @@
1
- uscha-kit 1.99.0
1
+ uscha-kit 2.1.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),
@@ -255,7 +255,7 @@ Implement per the PLAN. Commit per logical step with conventional commits
255
255
  - **Never edit the SPEC/ADR to make the implementation look correct.** If reality forces
256
256
  a change, amend the SPEC (version it) and return to Ready.
257
257
 
258
- ## Phase 2b — Simplicity gate ("Reduce")
258
+ ## Phase 2b — Simplicity check ("Reduce") — ADVISORY by default (kit 2.1.0)
259
259
 
260
260
  Before the QA loop, check the change isn't overbuilt. This is the CONSTITUTION's
261
261
  **Simplicidad** invariant made deterministic — diff minimality, nesting depth and new
@@ -266,11 +266,25 @@ git diff --unified=0 <base> | python3 $QL simplicity-check --config uscha.config
266
266
  # or: python3 $QL simplicity-check --from-git --base <base>
267
267
  ```
268
268
 
269
- Reads `SIMPLICITY: NN/100 — SIMPLE | ACCEPTABLE | OVERBUILT`. **OVERBUILT (exit 1) is a
270
- BLOCKER**: reduce first (guard clauses, drop speculative types/layers, split giant hunks)
271
- and re-run do not carry it into the QA loop or converge on it. The flags tell you exactly
272
- what to cut. Budgets live in `config.defaults.simplicity` (tighten per risk profile). For a
273
- 2-space codebase pass `--indent-width 2`.
269
+ Reads `SIMPLICITY: NN/100 — SIMPLE | ACCEPTABLE | OVERBUILT (advisory | declared gate)`.
270
+
271
+ **Advisory is the default and it exits 0** (ADR-043). Every budget is the KIT'S OPINION until
272
+ the project declares its own; an opinion that stops a loop is a gate nobody asked for. In
273
+ advisory mode an OVERBUILT verdict is **information for the human**: cut what is cheap to cut,
274
+ **report it in the PR body** with the score and the flags, and **never block on it** — do not
275
+ loop, do not refuse to converge, do not "fix" the diff to chase the number.
276
+
277
+ **It gates only when the project says so**: at least one declared budget in
278
+ `config.defaults.simplicity` **AND** `defaults.simplicity.gate: true` (or `--gate`). Then
279
+ OVERBUILT is exit 1 and a BLOCKER again: reduce first (guard clauses, drop speculative
280
+ types/layers, split giant hunks) and re-run. `gate: true` with **no** budget declared is a
281
+ config error, exit 2 — a gate with no budget is not a gate.
282
+
283
+ **`max_nesting` is an INDENTATION-DEPTH proxy, not AST nesting** — it counts leading
284
+ indentation on added lines. A wrapped call argument, JSX, or a multi-line Java/Kotlin literal
285
+ raises it with no control flow present at all, which is the single most common false OVERBUILT.
286
+ Discount it accordingly; the kit does not try to make it language-aware. For a 2-space codebase
287
+ pass `--indent-width 2`.
274
288
 
275
289
  **Tests are OUTSIDE the budget** (kit 1.11.0): test files (the 9 stack conventions) are
276
290
  counted and reported apart (`test_lines_added`) but never gate — writing tests must not
@@ -278,13 +292,22 @@ push a diff toward OVERBUILT (deleting them is already blocked by gate-check). A
278
292
  project can have MORE test code than production code.
279
293
 
280
294
  **Persist the verdict** so convergence and readiness see it (facts block through the
281
- ledger, not through your goodwill):
295
+ ledger, not through your goodwill) — and persist it as what it WAS:
282
296
 
283
297
  ```bash
298
+ # advisory mode (the default): the run is recorded, and it caps nothing and blocks nothing
299
+ python3 $QL log-gate --repo <REPO> --iteration <N> --kind simplicity \
300
+ --verdict advisory [--note "OVERBUILT 58/100 — advisory, no budget declared"]
301
+
302
+ # declared gate only (defaults.simplicity.gate: true + budgets)
284
303
  python3 $QL log-gate --repo <REPO> --iteration <N> --kind simplicity \
285
304
  --verdict <pass|fail> [--note "OVERBUILT: +612 lines vs 400 budget"]
286
305
  ```
287
306
 
307
+ **Never log an advisory run as `pass`.** `pass` means a declared gate ran and came back clean;
308
+ an advisory run means there was no gate. Readiness prints them apart (`N ok · 1 advisory`) and
309
+ the mirador shows `ADVISORY` instead of `OK` — but only if you tell it the truth here.
310
+
288
311
  ### Phase 2c — REUSE-FIRST gate (kit 1.26.0)
289
312
 
290
313
  Simplicity scores the diff in ISOLATION; it cannot see that the new block re-implements
@@ -314,7 +337,16 @@ profile A (trivial change) skip it. It **collapses into `readiness`** as a `gate
314
337
  ## Phase 3 — QA loop (per repo)
315
338
 
316
339
  Run the tools in `config.defaults.qa_tools_order` (default: code-review → judgment-day
317
- → improve). One pass of all tools = one cycle. After **each** tool pass:
340
+ → improve). One pass of all tools = one cycle.
341
+
342
+ Read the EFFECTIVE order, not the config file: since 2.0.0 `uscha init` generates a minimal
343
+ config, so `qa_tools_order` is often absent there and resolves from `defaults.risk_profile`
344
+ (ADR-001) or from the engine default. `qa_ledger.py doctor --json` prints it as
345
+ `effective.qa_tools_order` with its `origin`; the ledger froze the same value at `init`. A
346
+ project on **profile A runs `code-review` only** — do NOT invoke judgment-day or improve there,
347
+ and convergence must not wait for them.
348
+
349
+ After **each** tool pass:
318
350
 
319
351
  1. Apply only fixes at/above the severity gate. Send the rest to `ISSUES-DEFERRED.md`.
320
352
  2. Run the repo test command. If red and the fix isn't obvious → escalate.