@andresmassello/uscha 2.5.0 → 2.6.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.
Files changed (29) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/uscha-kit/.claude/skills/uscha-adr-refine/SKILL.md +1 -1
  4. package/uscha-kit/.claude/skills/uscha-characterize/SKILL.md +1 -1
  5. package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +35 -1
  6. package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +452 -22
  7. package/uscha-kit/.claude/skills/uscha-discovery/SKILL.md +1 -1
  8. package/uscha-kit/.claude/skills/uscha-mirador/SKILL.md +1 -1
  9. package/uscha-kit/.claude/skills/uscha-reverse-discovery/SKILL.md +1 -1
  10. package/uscha-kit/.claude/skills/uscha-rubric/SKILL.md +1 -1
  11. package/uscha-kit/.claude/skills/uscha-status/SKILL.md +1 -1
  12. package/uscha-kit/.claude/skills/uscha-sysdoc/SKILL.md +1 -1
  13. package/uscha-kit/.claude-plugin/plugin.json +2 -2
  14. package/uscha-kit/.codex-plugin/plugin.json +1 -1
  15. package/uscha-kit/README.md +34 -3
  16. package/uscha-kit/VERSION +1 -1
  17. package/uscha-kit/WORKBENCH.md +1 -1
  18. package/uscha-kit/skills/uscha-adr-refine/SKILL.md +1 -1
  19. package/uscha-kit/skills/uscha-characterize/SKILL.md +1 -1
  20. package/uscha-kit/skills/uscha-devloop/SKILL.md +35 -1
  21. package/uscha-kit/skills/uscha-devloop/qa_ledger.py +452 -22
  22. package/uscha-kit/skills/uscha-discovery/SKILL.md +1 -1
  23. package/uscha-kit/skills/uscha-mirador/SKILL.md +1 -1
  24. package/uscha-kit/skills/uscha-reverse-discovery/SKILL.md +1 -1
  25. package/uscha-kit/skills/uscha-rubric/SKILL.md +1 -1
  26. package/uscha-kit/skills/uscha-status/SKILL.md +1 -1
  27. package/uscha-kit/skills/uscha-sysdoc/SKILL.md +1 -1
  28. package/uscha-kit/templates/CONSTITUTION.md +14 -0
  29. package/uscha-kit/uscha.config.json +2 -1
@@ -2001,10 +2001,20 @@ ENGINE_DEFAULTS = {
2001
2001
  # release + reset + RUNBOOK on every project would be the kit's opinion wearing an exit
2002
2002
  # code -- the same thing ADR-043 refused for the simplicity budget.
2003
2003
  "operability.gate": False,
2004
+ # ADR-051. A HUMAN-declared list of QA tools that are harness built-ins or plugin-provided
2005
+ # and should be treated as present by `qa-tools-check` / `log-step` / `doctor`. It is NOT
2006
+ # profile-owned -- no risk profile supplies it, so `init` never writes it -- which is why it
2007
+ # lives here (the reporting table, never materialized) and not in RISK_PROFILES. It is read
2008
+ # through _effective_qa_external, PER KEY: a live config that declares it wins over the
2009
+ # frozen copy, one that omits it falls back to the frozen copy; its origin is
2010
+ # `live-config` / `frozen` / `default` (reported as "not declared" by doctor).
2011
+ "qa_tools_external": None,
2004
2012
  }
2005
2013
  ENGINE_DEFAULT_NOTES = {
2006
2014
  "qa_tools_order": "not declared - convergence uses a window of --tools-per-cycle "
2007
2015
  "agent steps",
2016
+ "qa_tools_external": "not declared - every tool in qa_tools_order must resolve to a "
2017
+ "skill, a plugin or a harness built-in (ADR-051)",
2008
2018
  }
2009
2019
 
2010
2020
 
@@ -2047,6 +2057,14 @@ def _resolved_defaults(cfg):
2047
2057
  return resolved, origin
2048
2058
 
2049
2059
 
2060
+ def _qa_external_shape_ok(ext):
2061
+ """defaults.qa_tools_external's one shape rule -- unique nonempty strings, `[]` allowed --
2062
+ shared by `init` and the live read (_effective_qa_external), so the two cannot drift."""
2063
+ return (isinstance(ext, list)
2064
+ and not any(not _has_text(tool) for tool in ext)
2065
+ and len(set(ext)) == len(ext))
2066
+
2067
+
2050
2068
  def _validate_init_config(cfg):
2051
2069
  """Validate only the engine's core init contract before creating a ledger."""
2052
2070
  if not isinstance(cfg, dict):
@@ -2101,6 +2119,15 @@ def _validate_init_config(cfg):
2101
2119
  raise SystemExit(
2102
2120
  "[qa_ledger] invalid config: qa_tools_order must contain unique nonempty strings")
2103
2121
 
2122
+ # ADR-051: a HUMAN declaration of harness/plugin-provided tools to treat as present. Same
2123
+ # shape as qa_tools_order (unique nonempty strings), but NOT profile-owned: init never
2124
+ # writes it, and declaring it is the documented escape from a MISSING qa-tools gate.
2125
+ if "qa_tools_external" in defaults:
2126
+ if not _qa_external_shape_ok(defaults["qa_tools_external"]):
2127
+ raise SystemExit(
2128
+ "[qa_ledger] invalid config: qa_tools_external must contain unique nonempty "
2129
+ "strings")
2130
+
2104
2131
  if "coverage_threshold" in defaults:
2105
2132
  value = defaults["coverage_threshold"]
2106
2133
  if (isinstance(value, bool) or not isinstance(value, (int, float))
@@ -2443,6 +2470,242 @@ def _to_bool(s):
2443
2470
  return str(s).strip().lower() in {"1", "true", "yes", "y", "ok", "pass", "passed"}
2444
2471
 
2445
2472
 
2473
+ # --------------------------------------------------------------------------- #
2474
+ # QA-tool resolver (ADR-051): "a declared QA tool is not installed" becomes a MEASURED fact.
2475
+ #
2476
+ # Phase 3 of the devloop runs QA tools (qa_tools_order) the kit ORCHESTRATES but does not ship.
2477
+ # Until 2.6.0 log-step accepted any tool name with no existence check and _converged only asked
2478
+ # whether a step was LOGGED -- so a loop could converge trusting a tool that never ran. The
2479
+ # resolver maps a tool name to a SOURCE; every source but MISSING counts as present.
2480
+ #
2481
+ # code-review is first in all five risk profiles and is a HARNESS BUILT-IN on a fresh Claude
2482
+ # Code machine (no skill dir, no plugin). The engine is a Python script and CANNOT introspect
2483
+ # what the harness provides, so a built-in is reported honestly as "assumed, harness-provided,
2484
+ # not measured" -- a DISTINCT source, never a false clean and never a FAIL. Without this a fresh
2485
+ # machine would have every loop blocked.
2486
+ HARNESS_BUILTINS = ("code-review",)
2487
+
2488
+
2489
+ def _claude_home(home=None):
2490
+ return home if home is not None else os.path.expanduser("~")
2491
+
2492
+
2493
+ def _claude_config_dir(home):
2494
+ """Claude Code's config directory: `CLAUDE_CONFIG_DIR` when the operator set it (the harness
2495
+ then reads its skills, plugins and settings THERE instead of ~/.claude), else <home>/.claude.
2496
+ Every Claude-side probe of the QA-tool resolver goes through here, so the env var is honoured
2497
+ in one place."""
2498
+ env = os.environ.get("CLAUDE_CONFIG_DIR")
2499
+ return env if env else os.path.join(home, ".claude")
2500
+
2501
+
2502
+ def _global_skill_roots(home):
2503
+ """(target, skills root) for every agent root the installer knows (SKILL_INSTALL_ROOTS -- the
2504
+ one table, never retyped here): a Codex or pi machine keeps its skills in its OWN root, and
2505
+ probing only ~/.claude/skills false-blocked it (2.6.0 fresh review, S2). The claude root
2506
+ follows CLAUDE_CONFIG_DIR; the others sit under the home, exactly where the installer puts
2507
+ them. Order is the table's, claude first."""
2508
+ roots = []
2509
+ for target, parts in SKILL_INSTALL_ROOTS:
2510
+ if target == "claude":
2511
+ roots.append((target, os.path.join(_claude_config_dir(home), "skills")))
2512
+ else:
2513
+ roots.append((target, os.path.join(home, *parts)))
2514
+ return roots
2515
+
2516
+
2517
+ def _sha256_file(path):
2518
+ try:
2519
+ with open(path, "rb") as fh:
2520
+ return hashlib.sha256(fh.read()).hexdigest()
2521
+ except OSError:
2522
+ return None
2523
+
2524
+
2525
+ def _enabled_plugins(home):
2526
+ """`<name>@<marketplace>` -> bool, merged from the SAME settings files doctor walks
2527
+ (user ~/.claude, project .claude, project-local; later wins per key). A missing or malformed
2528
+ file is skipped, never raised: a broken settings file must not turn a real tool MISSING."""
2529
+ paths = [os.path.join(_claude_config_dir(home), "settings.json"),
2530
+ os.path.join(".claude", "settings.json"),
2531
+ os.path.join(".claude", "settings.local.json")]
2532
+ enabled = {}
2533
+ for p in paths:
2534
+ try:
2535
+ with open(p, encoding="utf-8") as fh:
2536
+ data = json.load(fh)
2537
+ except (OSError, ValueError):
2538
+ continue
2539
+ ep = data.get("enabledPlugins") if isinstance(data, dict) else None
2540
+ if isinstance(ep, dict):
2541
+ for key, value in ep.items():
2542
+ enabled[key] = bool(value)
2543
+ return enabled
2544
+
2545
+
2546
+ def _installed_plugins(home):
2547
+ """`<name>@<marketplace>` -> list of install records, from ~/.claude/plugins/
2548
+ installed_plugins.json (shape: {"version": N, "plugins": {"<name>@<mkt>": [ {scope,
2549
+ installPath, projectPath?, ...}, ... ]}}). Being in a marketplace CACHE is not being
2550
+ installed -- only this file lists installs. Missing/malformed -> {}, never raises. A
2551
+ version-1 registry maps each key to ONE record (a dict) instead of a list;
2552
+ _resolve_plugin_tool accepts both shapes."""
2553
+ p = os.path.join(_claude_config_dir(home), "plugins", "installed_plugins.json")
2554
+ try:
2555
+ with open(p, encoding="utf-8") as fh:
2556
+ data = json.load(fh)
2557
+ except (OSError, ValueError):
2558
+ return {}
2559
+ plugins = data.get("plugins") if isinstance(data, dict) else None
2560
+ return plugins if isinstance(plugins, dict) else {}
2561
+
2562
+
2563
+ def _resolve_plugin_tool(name, home, cwd="."):
2564
+ """Resolve `name` against INSTALLED-AND-ENABLED plugins. `name` is either bare (`review`) or
2565
+ `<plugin>:<skill>` (the harness lists e.g. `open-code-review:review`). A plugin provides the
2566
+ tool when `skills/<skill>/SKILL.md` or `commands/<skill>.md` exists under one of its install
2567
+ paths (official code-review and open-code-review ship only `commands/`). Returns
2568
+ (path, sha256) or None. A `scope: project` record is honored only when its projectPath
2569
+ resolves to cwd (realpath BOTH sides -- the Windows 8.3 short-path gotcha); `scope: user`
2570
+ always. Never raises on a broken registry."""
2571
+ if ":" in name:
2572
+ plugin_part, skill_part = name.split(":", 1)
2573
+ else:
2574
+ plugin_part, skill_part = "", name
2575
+ if not skill_part:
2576
+ return None
2577
+ enabled = _enabled_plugins(home)
2578
+ installed = _installed_plugins(home)
2579
+ cwd_real = os.path.realpath(cwd)
2580
+ for key, records in installed.items():
2581
+ if not enabled.get(key):
2582
+ continue
2583
+ pname = key.split("@", 1)[0]
2584
+ if plugin_part and pname != plugin_part:
2585
+ continue
2586
+ if isinstance(records, dict):
2587
+ records = [records] # installed_plugins.json version 1: one record per key
2588
+ if not isinstance(records, list):
2589
+ continue
2590
+ for rec in records:
2591
+ if not isinstance(rec, dict):
2592
+ continue
2593
+ if rec.get("scope") == "project":
2594
+ pp = rec.get("projectPath")
2595
+ if not pp or os.path.realpath(pp) != cwd_real:
2596
+ continue
2597
+ ipath = rec.get("installPath")
2598
+ if not ipath or not isinstance(ipath, str):
2599
+ continue
2600
+ for cand in (os.path.join(ipath, "skills", skill_part, "SKILL.md"),
2601
+ os.path.join(ipath, "commands", skill_part + ".md")):
2602
+ if os.path.isfile(cand):
2603
+ return cand, _sha256_file(cand)
2604
+ return None
2605
+
2606
+
2607
+ def resolve_qa_tool(name, declared_external=(), project_root=".", home=None):
2608
+ """Map a QA tool name to a SOURCE. Six kinds; only MISSING blocks a loop. Resolution order
2609
+ is project > global > plugin > declared-external > builtin-assumed > MISSING (ADR-051):
2610
+
2611
+ project-skill ./.claude/skills/<name>/SKILL.md
2612
+ global-skill <root>/<name>/SKILL.md under ANY agent root the installer knows
2613
+ (SKILL_INSTALL_ROOTS: claude -- honouring CLAUDE_CONFIG_DIR --, codex,
2614
+ pi, cursor, copilot, gemini, cline); the row names the `root` that hit
2615
+ plugin an installed+enabled plugin (bare or <plugin>:<name>)
2616
+ builtin-assumed name in HARNESS_BUILTINS -- assumed, harness-provided, NOT measured
2617
+ declared-external the human listed it in defaults.qa_tools_external
2618
+ MISSING none of the above
2619
+
2620
+ For project/global/plugin the SKILL.md (or plugin command) path and its sha256 are returned;
2621
+ builtin-assumed / declared-external / MISSING carry neither (nothing was measured). Tool
2622
+ matching is LITERAL -- `review` and `open-code-review:review` are different names."""
2623
+ home = _claude_home(home)
2624
+ external = set(declared_external or ())
2625
+ ps = os.path.join(project_root, ".claude", "skills", name, "SKILL.md")
2626
+ if os.path.isfile(ps):
2627
+ return {"tool": name, "source": "project-skill", "path": ps,
2628
+ "sha256": _sha256_file(ps)}
2629
+ for target, root in _global_skill_roots(home):
2630
+ gs = os.path.join(root, name, "SKILL.md")
2631
+ if os.path.isfile(gs):
2632
+ return {"tool": name, "source": "global-skill", "root": target, "path": gs,
2633
+ "sha256": _sha256_file(gs)}
2634
+ plug = _resolve_plugin_tool(name, home, cwd=project_root)
2635
+ if plug is not None:
2636
+ return {"tool": name, "source": "plugin", "path": plug[0], "sha256": plug[1]}
2637
+ if name in external:
2638
+ return {"tool": name, "source": "declared-external", "path": None, "sha256": None}
2639
+ if name in HARNESS_BUILTINS:
2640
+ return {"tool": name, "source": "builtin-assumed", "path": None, "sha256": None}
2641
+ return {"tool": name, "source": "MISSING", "path": None, "sha256": None}
2642
+
2643
+
2644
+ QA_LIVE_CONFIG = "uscha.config.json"
2645
+
2646
+
2647
+ def _live_project_config(config, ledger_path):
2648
+ """The LIVE project config to read, or None: `config` in the cwd, then beside the ledger --
2649
+ the 1.98.0 `_config_beside_ledger` order. The kit's own REFERENCE uscha.config.json is never
2650
+ a project's live config (the 2.0.0 bug class: every knob in it would arrive as a human
2651
+ declaration), so a candidate that IS that file is skipped."""
2652
+ kit = _kit_root()
2653
+ ref = os.path.realpath(os.path.join(kit, QA_LIVE_CONFIG)) if kit else None
2654
+ cands = [config]
2655
+ if ledger_path and not os.path.isabs(config):
2656
+ cands.append(os.path.join(os.path.dirname(os.path.abspath(ledger_path)), config))
2657
+ for cand in cands:
2658
+ if os.path.isfile(cand) and os.path.realpath(cand) != ref:
2659
+ return cand
2660
+ return None
2661
+
2662
+
2663
+ def _effective_qa_external(frozen_cfg, ledger_path, config=QA_LIVE_CONFIG):
2664
+ """The effective defaults.qa_tools_external -- ONE function for `qa-tools-check`, `log-step`
2665
+ provenance and `doctor` (2.6.0 fresh review, S1: the gate read only the frozen copy while
2666
+ doctor reported the live file, so the two disagreed and the gate's own remediation was a
2667
+ dead end on any initialised ledger).
2668
+
2669
+ The list is a fact about the MACHINE -- which tools are installed -- not a run parameter, so
2670
+ a LIVE declaration wins over the ledger's frozen copy: declaring a tool after `init` takes
2671
+ effect without re-init, and nothing writes the ledger (its integrity hash is untouched).
2672
+ Both copies go through the same `_resolved_defaults` machinery doctor uses. Only this key
2673
+ moves: qa_tools_order stays the frozen run parameter.
2674
+
2675
+ Precedence is PER KEY (2.6.0 review): a live file that DECLARES the key -- an explicit `[]`
2676
+ included, a deliberate withdrawal -- wins; a live file that OMITS it falls back to the frozen
2677
+ copy, then to the default. Strict-live (any live file wins) read an unrelated live config as
2678
+ "nothing declared" and silently discarded the frozen declaration -- a false MISSING block.
2679
+
2680
+ Returns {"value": [...], "origin": "live-config" | "frozen" | "default", "path": file|None}.
2681
+ `default` means declared nowhere; `path` is the live file only when it is the declaring one.
2682
+ A live file that EXISTS but cannot be read or resolved, or that declares the key with the
2683
+ wrong shape, refuses, naming it -- answering "none declared" or falling back to the frozen
2684
+ copy would answer from something other than what the human wrote."""
2685
+ live = _live_project_config(config, ledger_path)
2686
+ if live is not None:
2687
+ cfg = _load(live, what="config", flag="--config")
2688
+ try:
2689
+ resolved, live_origin = _resolved_defaults(cfg)
2690
+ except SystemExit as exc:
2691
+ raise SystemExit("[qa_ledger] %s: cannot resolve defaults.qa_tools_external: %s"
2692
+ % (live, exc))
2693
+ live_declares = live_origin.get("qa_tools_external") == "override"
2694
+ ext = resolved.get("qa_tools_external")
2695
+ if live_declares and not _qa_external_shape_ok(ext):
2696
+ raise SystemExit("[qa_ledger] %s: invalid defaults.qa_tools_external (got %s): it "
2697
+ "must be a list of unique nonempty strings -- refusing rather than "
2698
+ "reading it as none declared" % (live, ascii(ext)))
2699
+ if live_declares:
2700
+ return {"value": list(ext), "origin": "live-config", "path": live}
2701
+ # the live file omits the key: the frozen declaration (or the default) stands
2702
+ resolved, origin = _resolved_defaults(frozen_cfg or {})
2703
+ ext = resolved.get("qa_tools_external")
2704
+ declared = origin.get("qa_tools_external") == "override"
2705
+ return {"value": list(ext) if isinstance(ext, list) else [],
2706
+ "origin": "frozen" if declared else "default", "path": None}
2707
+
2708
+
2446
2709
  def cmd_log_step(args):
2447
2710
  ledger = _load(args.ledger)
2448
2711
  node = _repo_node(ledger, args.repo)
@@ -2454,6 +2717,16 @@ def cmd_log_step(args):
2454
2717
  if args.fingerprint:
2455
2718
  ids = sorted(x.strip() for x in args.fingerprint.split(",") if x.strip())
2456
2719
  fp = hashlib.sha1(("|".join(ids)).encode()).hexdigest()[:12] if ids else None
2720
+ # ADR-051: stamp the step with the resolved SOURCE of its tool (and the SKILL.md / plugin
2721
+ # hash where one was measured). A tool that resolves to MISSING is recorded unverified and
2722
+ # does NOT count toward convergence -- a loop can no longer converge trusting a QA tool that
2723
+ # is not installed. Steps written before 2.6.0 carry no `source`/`unverified`: they still
2724
+ # load and are treated exactly as today (a verified step). The stamp is taken at LOG time: a
2725
+ # step logged while its tool was MISSING stays unverified after the tool is installed --
2726
+ # re-log it. The declared-external list is the LIVE config's when it declares one (S1), and a
2727
+ # declared-external step records which copy declared it.
2728
+ ext = _effective_qa_external(ledger.get("config") or {}, args.ledger)
2729
+ res = resolve_qa_tool(args.tool, ext["value"], project_root=".")
2457
2730
  record = {
2458
2731
  "n": ledger["step_counter"],
2459
2732
  "at": _now(),
@@ -2470,7 +2743,16 @@ def cmd_log_step(args):
2470
2743
  "fingerprint": fp,
2471
2744
  "finding_ids": ids,
2472
2745
  "note": args.note,
2746
+ "source": res["source"],
2473
2747
  }
2748
+ if res["sha256"]:
2749
+ record["source_sha256"] = res["sha256"]
2750
+ if res.get("root"):
2751
+ record["source_root"] = res["root"]
2752
+ if res["source"] == "declared-external":
2753
+ record["source_origin"] = ext["origin"]
2754
+ if res["source"] == "MISSING":
2755
+ record["unverified"] = True
2474
2756
  node["iterations"].append(record)
2475
2757
  ledger["steps"].append({"n": record["n"], "at": record["at"], "kind": "qa-step",
2476
2758
  "repo": args.repo, "tool": args.tool,
@@ -2479,7 +2761,10 @@ def cmd_log_step(args):
2479
2761
  print(f"[qa_ledger] step #{record['n']} logged: {args.repo}/{args.tool} "
2480
2762
  f"iter={args.iteration} reported={args.reported} "
2481
2763
  f"gated={args.gated_reported} fixed={args.fixed} "
2482
- f"tests_passed={record['tests_passed']} files_changed={args.files_changed}")
2764
+ f"tests_passed={record['tests_passed']} files_changed={args.files_changed} "
2765
+ f"source={res['source']}"
2766
+ + ("" if res["source"] != "MISSING" else
2767
+ " (unverified: tool not installed -- does not count toward convergence)"))
2483
2768
 
2484
2769
 
2485
2770
  def cmd_ingest_gate(args):
@@ -3634,13 +3919,25 @@ def _converged(node, k, qa_order=None):
3634
3919
  reasons = []
3635
3920
  k = max(1, k)
3636
3921
  if qa_order:
3637
- latest_agent = {}
3638
- for s in agent:
3639
- latest_agent[s["tool"]] = s # iteration order preserved -> last wins
3640
- missing = [t for t in qa_order if t not in latest_agent]
3641
- if missing:
3642
- reasons.append(f"agent tools never ran: {','.join(missing)}")
3643
- cycle = [latest_agent[t] for t in qa_order if t in latest_agent]
3922
+ # ADR-051: a listed tool is satisfied only by a step that both EXISTS and RESOLVED. A
3923
+ # step whose tool was MISSING at log time (unverified) ran a QA tool that is not
3924
+ # installed, so it can no longer stand in for the tool -- it is reported on its own line,
3925
+ # distinct from "never ran". Steps written before 2.6.0 have no `unverified` key, so
3926
+ # `.get` is falsy and they are verified exactly as today (old-ledger compatibility).
3927
+ latest_seen, latest_verified = {}, {}
3928
+ for s in agent: # iteration order preserved -> last wins
3929
+ latest_seen[s["tool"]] = s
3930
+ if s.get("unverified") is not True:
3931
+ latest_verified[s["tool"]] = s
3932
+ never = [t for t in qa_order if t not in latest_seen]
3933
+ unverified = [t for t in qa_order
3934
+ if t in latest_seen and t not in latest_verified]
3935
+ if never:
3936
+ reasons.append(f"agent tools never ran: {','.join(never)}")
3937
+ if unverified:
3938
+ reasons.append("agent tools ran unverified (tool MISSING at log time): "
3939
+ f"{','.join(unverified)}")
3940
+ cycle = [latest_verified[t] for t in qa_order if t in latest_verified]
3644
3941
  if not agent:
3645
3942
  return False, ["no agent steps measured"]
3646
3943
  else:
@@ -3782,6 +4079,17 @@ def _derive_phase(ledger, name, node, k, qa_order):
3782
4079
  reasons.append("operability: %s -- release, reset and the RUNBOOK are part of done, "
3783
4080
  "not of the last week (ADR-048)"
3784
4081
  % (_op.get("note") or "checks missing"))
4082
+ # qa-tools gate (ADR-051). NAMING only: a failing gate:qa-tools record is already a BLOCKER
4083
+ # through _gate_open_and_sev and already vetoes convergence, so nothing new is gated here.
4084
+ # What is added is WHICH declared QA tool is not installed -- "static-gate gated=1
4085
+ # (gate:qa-tools:1)" tells a human the gate is red without telling them which tool, and
4086
+ # `phase --require pr-ready` is where that answer is needed.
4087
+ _qt = _latest_static_by_tool(node).get("gate:qa-tools")
4088
+ if _qt and (_qt.get("gated_reported") or 0) > 0:
4089
+ reasons.append("qa tools: %s -- a declared QA tool is not installed; install it or "
4090
+ "declare it in defaults.qa_tools_external, then re-run "
4091
+ "qa-tools-check (ADR-051)"
4092
+ % (_qt.get("note") or "tool(s) missing"))
3785
4093
  _cr = _cr_cfg(ledger)
3786
4094
  if _cr and _cr.get("mode") == "final":
3787
4095
  _head = None
@@ -4730,6 +5038,82 @@ def cmd_operability(args):
4730
5038
  sys.exit(0)
4731
5039
 
4732
5040
 
5041
+ # --------------------------------------------------------------------------- #
5042
+ # qa-tools-check (ADR-051: a declared QA tool that is not installed is a MEASURED fact)
5043
+ # --------------------------------------------------------------------------- #
5044
+ def cmd_qa_tools_check(args):
5045
+ """Resolve every tool in the EFFECTIVE qa_tools_order and persist gate:qa-tools.
5046
+
5047
+ Unlike `operability` (always exit 0, the gate is the profile's), this check exits 1 when a
5048
+ tool is MISSING: "a declared tool is not installed" is a fact under EVERY profile (ADR-043:
5049
+ a fact blocks, an opinion does not), so there is no profile knob here. A MISSING tool makes
5050
+ the record a FAIL -- caps readiness <=65 and blocks convergence through the same plumbing
5051
+ every other FACT gate uses. builtin-assumed and declared-external count as present and are
5052
+ reported honestly. With no qa_tools_order declared there is nothing to resolve: UNMEASURED,
5053
+ exit 0, persist nothing -- a list is never synthesized."""
5054
+ ledger = _load(args.ledger)
5055
+ node = _repo_node(ledger, args.repo)
5056
+ resolved, _origin = _resolved_defaults(ledger.get("config") or {})
5057
+ qa_order = resolved.get("qa_tools_order")
5058
+
5059
+ if not qa_order:
5060
+ # NO-ORDER: window convergence, no list. Nothing to resolve.
5061
+ report = {"repo": args.repo, "qa_tools_order": None, "verdict": "unmeasured",
5062
+ "tools": [], "missing": [],
5063
+ "note": "no qa_tools_order declared -- convergence uses a window of agent "
5064
+ "steps; there is no QA-tool list to resolve (UNMEASURED, not a pass)"}
5065
+ if args.json:
5066
+ print(json.dumps(report, indent=2, ensure_ascii=False))
5067
+ else:
5068
+ print("QA-TOOLS %s: UNMEASURED -- no qa_tools_order declared; nothing to resolve "
5069
+ "(a list is never synthesized)" % args.repo)
5070
+ sys.exit(0)
5071
+
5072
+ ext = _effective_qa_external(ledger.get("config") or {}, args.ledger)
5073
+ results = [resolve_qa_tool(t, ext["value"], project_root=".") for t in qa_order]
5074
+ missing = [r["tool"] for r in results if r["source"] == "MISSING"]
5075
+ note = "; ".join("%s=%s" % (r["tool"], r["source"]) for r in results)
5076
+ rec = _append_gate_record(ledger, node, args.repo, "gate:qa-tools", args.iteration,
5077
+ bool(missing), len(missing) or 1, note, advisory=False)
5078
+ _save(args.ledger, ledger)
5079
+
5080
+ report = {"repo": args.repo, "qa_tools_order": list(qa_order),
5081
+ "verdict": "fail" if missing else "pass", "missing": missing,
5082
+ "tools": results, "note": note, "step": rec["n"],
5083
+ "qa_tools_external": ext}
5084
+ if args.json:
5085
+ print(json.dumps(report, indent=2, ensure_ascii=False))
5086
+ sys.exit(1 if missing else 0)
5087
+
5088
+ print("QA-TOOLS %s" % args.repo)
5089
+ print(" qa_tools_external = %s (origin: %s%s)"
5090
+ % (", ".join(ext["value"]) or "none declared", ext["origin"],
5091
+ (" " + ext["path"]) if ext["path"] else ""))
5092
+ for r in results:
5093
+ mark = "!!" if r["source"] == "MISSING" else "ok"
5094
+ if r["source"] in ("project-skill", "global-skill", "plugin") and r.get("path"):
5095
+ detail = " -> %s (%s)" % (r["path"], (r["sha256"] or "")[:12])
5096
+ if r.get("root"):
5097
+ detail += " [%s root]" % r["root"]
5098
+ elif r["source"] == "builtin-assumed":
5099
+ detail = " (assumed, harness-provided, not measured)"
5100
+ elif r["source"] == "declared-external":
5101
+ detail = " (declared in defaults.qa_tools_external, %s)" % ext["origin"]
5102
+ else:
5103
+ detail = ""
5104
+ print(" %s %s: %s%s" % (mark, r["tool"], r["source"], detail))
5105
+ if missing:
5106
+ where = ext["path"] or QA_LIVE_CONFIG
5107
+ print(" -- FAIL: %s not installed -- caps readiness <=65 and blocks convergence until "
5108
+ "resolved: install the tool, or, if it is harness-provided, declare it in "
5109
+ "defaults.qa_tools_external of %s and re-run this check (a live declaration wins "
5110
+ "over the ledger's frozen copy; no re-init)" % (", ".join(missing), where))
5111
+ sys.exit(1)
5112
+ print(" -- PASS: every declared QA tool resolves (builtin-assumed / declared-external "
5113
+ "count as present, reported honestly)")
5114
+ sys.exit(0)
5115
+
5116
+
4733
5117
  # --------------------------------------------------------------------------- #
4734
5118
  # golden-coverage (ADR-006: the golden<->source mapping, DERIVED BY MEASUREMENT)
4735
5119
  # --------------------------------------------------------------------------- #
@@ -14241,8 +14625,25 @@ def cmd_doctor(args):
14241
14625
 
14242
14626
  # --- proyecto (si hay config aca) ---------------------------------------
14243
14627
  qa_order = ["code-review", "judgment-day", "improve"] # default del kit
14628
+ qa_external = [] # ADR-051, declared by the human
14244
14629
  effective, risk_profile = None, None
14245
14630
  cfg_path = args.config or "uscha.config.json"
14631
+ # ADR-051 / S1: qa_tools_external through the SAME function the gate and log-step use --
14632
+ # a live declaration wins over the ledger's frozen copy, per key -- so doctor and
14633
+ # `qa-tools-check` can never report a different value or origin for it.
14634
+ frozen_cfg = None
14635
+ if os.path.isfile(args.ledger):
14636
+ try:
14637
+ frozen_cfg = _load(args.ledger).get("config") or {}
14638
+ except SystemExit:
14639
+ frozen_cfg = None # reported by the ledger check below
14640
+ try:
14641
+ qa_ext_info = _effective_qa_external(frozen_cfg, args.ledger, config=cfg_path)
14642
+ except SystemExit as exc:
14643
+ qa_ext_info = None
14644
+ warn("qa_tools_external not resolvable", str(exc))
14645
+ if qa_ext_info is not None:
14646
+ qa_external = qa_ext_info["value"]
14246
14647
  if os.path.isfile(cfg_path):
14247
14648
  try:
14248
14649
  cfg = _load(cfg_path)
@@ -14289,17 +14690,30 @@ def cmd_doctor(args):
14289
14690
  if origin is not None:
14290
14691
  effective = {k: {"value": resolved.get(k), "origin": origin[k]}
14291
14692
  for k in ENGINE_DEFAULTS}
14693
+ if qa_ext_info is not None:
14694
+ # the one key read through _effective_qa_external (S1): its origin axis is
14695
+ # live-config / frozen / default, exactly what `qa-tools-check` reports
14696
+ effective["qa_tools_external"] = {"value": qa_ext_info["value"],
14697
+ "origin": qa_ext_info["origin"]}
14698
+ else:
14699
+ # unresolvable (warned above, naming why): ABSENT, never the raw value under
14700
+ # an `override` origin its live-config / frozen / default axis does not have
14701
+ effective.pop("qa_tools_external", None)
14292
14702
  ok("risk profile: %s" % (risk_profile or "none declared"),
14293
14703
  "effective settings below - precedence: override > profile > default")
14294
14704
  for key in ENGINE_DEFAULTS:
14295
- value = resolved.get(key)
14296
- if isinstance(value, list):
14705
+ if key not in effective:
14706
+ continue
14707
+ value = effective[key]["value"]
14708
+ if isinstance(value, list) and value:
14297
14709
  shown = ", ".join(value)
14298
- elif value is None:
14710
+ elif value is None or value == []:
14299
14711
  shown = ENGINE_DEFAULT_NOTES.get(key, "not declared")
14300
14712
  else:
14301
14713
  shown = value
14302
- detail = "origin: " + origin[key]
14714
+ detail = "origin: " + effective[key]["origin"]
14715
+ if key == "qa_tools_external" and qa_ext_info and qa_ext_info["path"]:
14716
+ detail += " " + qa_ext_info["path"]
14303
14717
  if (origin[key] == "override" and risk_profile
14304
14718
  and key in RISK_PROFILES.get(risk_profile, {})):
14305
14719
  detail += (" - this override supersedes profile %s (information: an "
@@ -14355,17 +14769,22 @@ def cmd_doctor(args):
14355
14769
  "as amended) - only needed to RUN the loop here")
14356
14770
 
14357
14771
  # --- skills de QA del loop (externas al kit, se orquestan sin traerlas) --
14358
- # sin ellas la fase 3 (QA loop) no corre; chequeables con o sin config.
14359
- missing_qa = [t for t in qa_order
14360
- if not os.path.isdir(os.path.join(".claude", "skills", t))
14361
- and not os.path.isdir(os.path.join(home_skills, t))]
14772
+ # sin ellas la fase 3 (QA loop) no corre; chequeables con o sin config. ADR-051: resolved
14773
+ # through the SAME six-source resolver `qa-tools-check` uses, so a plugin/builtin-assumed/
14774
+ # declared-external tool no longer false-warns here (code-review used to). doctor stays a
14775
+ # WARN-only lens (exit code unchanged): the blocking gate is `qa-tools-check`. A MISSING
14776
+ # tool is a warn naming it and pointing at that subcommand.
14777
+ qa_res = [resolve_qa_tool(t, qa_external, project_root=".") for t in qa_order]
14778
+ missing_qa = [r["tool"] for r in qa_res if r["source"] == "MISSING"]
14362
14779
  if not missing_qa:
14363
- ok(f"skills de QA del loop instaladas: {', '.join(qa_order)}")
14780
+ shown = ", ".join("%s (%s)" % (r["tool"], r["source"]) for r in qa_res)
14781
+ ok(f"QA tools del loop resueltas: {shown}")
14364
14782
  else:
14365
- warn(f"skills de QA no encontradas como archivo: {', '.join(missing_qa)}",
14366
- "install your QA skills in ~/.claude/skills/ or declare others in "
14367
- "config.defaults.qa_tools_order; if it is a harness built-in "
14368
- "(e.g. code-review), ignore this notice")
14783
+ warn(f"QA tools no instaladas: {', '.join(missing_qa)}",
14784
+ "install them in your agent's skills root (~/.claude/skills/, ~/.agents/skills/, "
14785
+ "... or a project .claude/skills/), enable a plugin that provides them, or "
14786
+ "declare them in config.defaults.qa_tools_external if they are harness-provided "
14787
+ "-- `qa-tools-check` is the gate that BLOCKS on this fact")
14369
14788
 
14370
14789
  # --- veredicto ----------------------------------------------------------
14371
14790
  n_ok = sum(1 for lv, _, _ in checks if lv == "ok")
@@ -14806,6 +15225,15 @@ def build_parser():
14806
15225
  pop.add_argument("--iteration", type=int, default=1)
14807
15226
  pop.add_argument("--json", action="store_true")
14808
15227
  pop.set_defaults(func=cmd_operability)
15228
+ pqt = sub.add_parser("qa-tools-check",
15229
+ help="resolve every tool in the EFFECTIVE qa_tools_order and persist "
15230
+ "gate:qa-tools (ADR-051); exit 1 when a declared QA tool is not "
15231
+ "installed -- a fact that blocks under every profile")
15232
+ add_ledger(pqt)
15233
+ pqt.add_argument("--repo", required=True)
15234
+ pqt.add_argument("--iteration", type=int, default=1)
15235
+ pqt.add_argument("--json", action="store_true")
15236
+ pqt.set_defaults(func=cmd_qa_tools_check)
14809
15237
  pre = sub.add_parser("resolve-escalation",
14810
15238
  help="close open escalations for a repo (recorded event; "
14811
15239
  "lifts the readiness cap)")
@@ -14832,7 +15260,9 @@ def build_parser():
14832
15260
  "it. smoke (ADR-047) is the same door for a smoke run measured "
14833
15261
  "elsewhere -- a FACT kind, so advisory is refused on it. "
14834
15262
  "operability (ADR-048) is accepted for parity with the "
14835
- "`operability` subcommand, which is what normally writes it")
15263
+ "`operability` subcommand, which is what normally writes it. "
15264
+ "gate:qa-tools (ADR-051) has NO door here: the engine measures it "
15265
+ "itself, and only `qa-tools-check` writes it")
14836
15266
  plg.add_argument("--verdict", required=True,
14837
15267
  choices=["pass", "fail", "advisory", "not-run"],
14838
15268
  help="advisory (ADR-043) records a measured, non-gating run: it never "
@@ -19,7 +19,7 @@ shape.** Your job is to interrogate until there is a shared system shape, and to
19
19
  the documents as you go — not to ask the human to design the system for you.
20
20
 
21
21
  <!-- uscha:orientation-block:begin -->
22
- <!-- uscha kit: 2.5.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
22
+ <!-- uscha kit: 2.6.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
23
23
 
24
24
  ## First contact (show ONCE, then never again)
25
25
 
@@ -18,7 +18,7 @@ Paints the REAL state of the project at a glance. It does not narrate or estimat
18
18
  wires the JSON the engine emits into the template. Read-only.
19
19
 
20
20
  <!-- uscha:orientation-block:begin -->
21
- <!-- uscha kit: 2.5.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
21
+ <!-- uscha kit: 2.6.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
22
22
 
23
23
  ## Orientation markers (non-negotiable)
24
24
 
@@ -24,7 +24,7 @@ evidence-classed, content-addressed, and promoted to the contract only by a per-
24
24
  human verdict (ADR-013).**
25
25
 
26
26
  <!-- uscha:orientation-block:begin -->
27
- <!-- uscha kit: 2.5.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
27
+ <!-- uscha kit: 2.6.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
28
28
 
29
29
  ## First contact (show ONCE, then never again)
30
30
 
@@ -22,7 +22,7 @@ the grader — this skill just wraps the neutral prompt so Claude Code users get
22
22
  in one command. Never add Claude-specific behavior to the contract.
23
23
 
24
24
  <!-- uscha:orientation-block:begin -->
25
- <!-- uscha kit: 2.5.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
25
+ <!-- uscha kit: 2.6.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
26
26
 
27
27
  ## First contact (show ONCE, then never again)
28
28
 
@@ -58,7 +58,7 @@ that surface the warning cannot come from the skill itself. The `doctor` seam is
58
58
  that still works there — it runs from any kit checkout and reads the installs from outside.
59
59
 
60
60
  <!-- uscha:orientation-block:begin -->
61
- <!-- uscha kit: 2.5.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
61
+ <!-- uscha kit: 2.6.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
62
62
 
63
63
  ## Orientation markers (non-negotiable)
64
64
 
@@ -22,7 +22,7 @@ switch between at any time:
22
22
  coverage, known deferred issues.
23
23
 
24
24
  <!-- uscha:orientation-block:begin -->
25
- <!-- uscha kit: 2.5.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
25
+ <!-- uscha kit: 2.6.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
26
26
 
27
27
  ## First contact (show ONCE, then never again)
28
28