@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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-adr-refine/SKILL.md +1 -1
- package/uscha-kit/.claude/skills/uscha-characterize/SKILL.md +1 -1
- package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +35 -1
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +452 -22
- package/uscha-kit/.claude/skills/uscha-discovery/SKILL.md +1 -1
- package/uscha-kit/.claude/skills/uscha-mirador/SKILL.md +1 -1
- package/uscha-kit/.claude/skills/uscha-reverse-discovery/SKILL.md +1 -1
- package/uscha-kit/.claude/skills/uscha-rubric/SKILL.md +1 -1
- package/uscha-kit/.claude/skills/uscha-status/SKILL.md +1 -1
- package/uscha-kit/.claude/skills/uscha-sysdoc/SKILL.md +1 -1
- package/uscha-kit/.claude-plugin/plugin.json +2 -2
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +34 -3
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/WORKBENCH.md +1 -1
- package/uscha-kit/skills/uscha-adr-refine/SKILL.md +1 -1
- package/uscha-kit/skills/uscha-characterize/SKILL.md +1 -1
- package/uscha-kit/skills/uscha-devloop/SKILL.md +35 -1
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +452 -22
- package/uscha-kit/skills/uscha-discovery/SKILL.md +1 -1
- package/uscha-kit/skills/uscha-mirador/SKILL.md +1 -1
- package/uscha-kit/skills/uscha-reverse-discovery/SKILL.md +1 -1
- package/uscha-kit/skills/uscha-rubric/SKILL.md +1 -1
- package/uscha-kit/skills/uscha-status/SKILL.md +1 -1
- package/uscha-kit/skills/uscha-sysdoc/SKILL.md +1 -1
- package/uscha-kit/templates/CONSTITUTION.md +14 -0
- 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
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
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
|
-
|
|
14296
|
-
|
|
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: " +
|
|
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
|
-
|
|
14360
|
-
|
|
14361
|
-
|
|
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
|
-
|
|
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"
|
|
14366
|
-
"install your
|
|
14367
|
-
"
|
|
14368
|
-
"
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|