@andresmassello/uscha 2.5.0 → 2.7.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 +14 -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 +469 -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/INSTALL.md +7 -0
- package/uscha-kit/README.md +49 -3
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/WORKBENCH.md +1 -1
- package/uscha-kit/install-uscha.py +215 -2
- 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 +469 -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,259 @@ 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, cwd="."):
|
|
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
|
+
The project files are read under `cwd` (the resolver's project root; "." for every engine
|
|
2530
|
+
caller), so a machine-level caller -- the installer's extras report -- can keep the
|
|
2531
|
+
directory it happens to run from out of the answer."""
|
|
2532
|
+
paths = [os.path.join(_claude_config_dir(home), "settings.json"),
|
|
2533
|
+
os.path.join(cwd, ".claude", "settings.json"),
|
|
2534
|
+
os.path.join(cwd, ".claude", "settings.local.json")]
|
|
2535
|
+
enabled = {}
|
|
2536
|
+
for p in paths:
|
|
2537
|
+
try:
|
|
2538
|
+
with open(p, encoding="utf-8") as fh:
|
|
2539
|
+
data = json.load(fh)
|
|
2540
|
+
except (OSError, ValueError):
|
|
2541
|
+
continue
|
|
2542
|
+
ep = data.get("enabledPlugins") if isinstance(data, dict) else None
|
|
2543
|
+
if isinstance(ep, dict):
|
|
2544
|
+
for key, value in ep.items():
|
|
2545
|
+
enabled[key] = bool(value)
|
|
2546
|
+
return enabled
|
|
2547
|
+
|
|
2548
|
+
|
|
2549
|
+
def _installed_plugins(home):
|
|
2550
|
+
"""`<name>@<marketplace>` -> list of install records, from ~/.claude/plugins/
|
|
2551
|
+
installed_plugins.json (shape: {"version": N, "plugins": {"<name>@<mkt>": [ {scope,
|
|
2552
|
+
installPath, projectPath?, ...}, ... ]}}). Being in a marketplace CACHE is not being
|
|
2553
|
+
installed -- only this file lists installs. Missing/malformed -> {}, never raises. A
|
|
2554
|
+
version-1 registry maps each key to ONE record (a dict) instead of a list;
|
|
2555
|
+
_resolve_plugin_tool accepts both shapes."""
|
|
2556
|
+
p = os.path.join(_claude_config_dir(home), "plugins", "installed_plugins.json")
|
|
2557
|
+
try:
|
|
2558
|
+
with open(p, encoding="utf-8") as fh:
|
|
2559
|
+
data = json.load(fh)
|
|
2560
|
+
except (OSError, ValueError):
|
|
2561
|
+
return {}
|
|
2562
|
+
plugins = data.get("plugins") if isinstance(data, dict) else None
|
|
2563
|
+
return plugins if isinstance(plugins, dict) else {}
|
|
2564
|
+
|
|
2565
|
+
|
|
2566
|
+
def enabled_plugin_installs(home, cwd=".", plugin=None):
|
|
2567
|
+
"""[(`<name>@<marketplace>`, installPath)] for every plugin that is installed AND enabled --
|
|
2568
|
+
the plugin-level half of the registry reader, shared by the QA-tool resolver below and the
|
|
2569
|
+
installer's optional-extras report (ADR-052), which asks about a PLUGIN (engram), not a
|
|
2570
|
+
skill, and must not push a plugin name through the skill resolver. `plugin` filters by the
|
|
2571
|
+
name part before the `@`. A `scope: project` record counts only when its projectPath resolves
|
|
2572
|
+
to `cwd` (realpath BOTH sides -- the Windows 8.3 short-path gotcha); `scope: user` always.
|
|
2573
|
+
Both registry shapes are read (version 1 maps a key to ONE record). Never raises on a broken
|
|
2574
|
+
registry: a malformed file reads as nothing installed."""
|
|
2575
|
+
enabled = _enabled_plugins(home, cwd)
|
|
2576
|
+
installed = _installed_plugins(home)
|
|
2577
|
+
cwd_real = os.path.realpath(cwd)
|
|
2578
|
+
out = []
|
|
2579
|
+
for key, records in installed.items():
|
|
2580
|
+
if not enabled.get(key):
|
|
2581
|
+
continue
|
|
2582
|
+
if plugin and key.split("@", 1)[0] != plugin:
|
|
2583
|
+
continue
|
|
2584
|
+
if isinstance(records, dict):
|
|
2585
|
+
records = [records] # installed_plugins.json version 1: one record per key
|
|
2586
|
+
if not isinstance(records, list):
|
|
2587
|
+
continue
|
|
2588
|
+
for rec in records:
|
|
2589
|
+
if not isinstance(rec, dict):
|
|
2590
|
+
continue
|
|
2591
|
+
if rec.get("scope") == "project":
|
|
2592
|
+
pp = rec.get("projectPath")
|
|
2593
|
+
if not pp or os.path.realpath(pp) != cwd_real:
|
|
2594
|
+
continue
|
|
2595
|
+
ipath = rec.get("installPath")
|
|
2596
|
+
if not ipath or not isinstance(ipath, str):
|
|
2597
|
+
continue
|
|
2598
|
+
out.append((key, ipath))
|
|
2599
|
+
return out
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
def _resolve_plugin_tool(name, home, cwd="."):
|
|
2603
|
+
"""Resolve `name` against INSTALLED-AND-ENABLED plugins. `name` is either bare (`review`) or
|
|
2604
|
+
`<plugin>:<skill>` (the harness lists e.g. `open-code-review:review`). A plugin provides the
|
|
2605
|
+
tool when `skills/<skill>/SKILL.md` or `commands/<skill>.md` exists under one of its install
|
|
2606
|
+
paths (official code-review and open-code-review ship only `commands/`). Returns
|
|
2607
|
+
(path, sha256) or None. A `scope: project` record is honored only when its projectPath
|
|
2608
|
+
resolves to cwd (realpath BOTH sides -- the Windows 8.3 short-path gotcha); `scope: user`
|
|
2609
|
+
always. Never raises on a broken registry."""
|
|
2610
|
+
if ":" in name:
|
|
2611
|
+
plugin_part, skill_part = name.split(":", 1)
|
|
2612
|
+
else:
|
|
2613
|
+
plugin_part, skill_part = "", name
|
|
2614
|
+
if not skill_part:
|
|
2615
|
+
return None
|
|
2616
|
+
for _key, ipath in enabled_plugin_installs(home, cwd=cwd, plugin=plugin_part or None):
|
|
2617
|
+
for cand in (os.path.join(ipath, "skills", skill_part, "SKILL.md"),
|
|
2618
|
+
os.path.join(ipath, "commands", skill_part + ".md")):
|
|
2619
|
+
if os.path.isfile(cand):
|
|
2620
|
+
return cand, _sha256_file(cand)
|
|
2621
|
+
return None
|
|
2622
|
+
|
|
2623
|
+
|
|
2624
|
+
def resolve_qa_tool(name, declared_external=(), project_root=".", home=None):
|
|
2625
|
+
"""Map a QA tool name to a SOURCE. Six kinds; only MISSING blocks a loop. Resolution order
|
|
2626
|
+
is project > global > plugin > declared-external > builtin-assumed > MISSING (ADR-051):
|
|
2627
|
+
|
|
2628
|
+
project-skill ./.claude/skills/<name>/SKILL.md
|
|
2629
|
+
global-skill <root>/<name>/SKILL.md under ANY agent root the installer knows
|
|
2630
|
+
(SKILL_INSTALL_ROOTS: claude -- honouring CLAUDE_CONFIG_DIR --, codex,
|
|
2631
|
+
pi, cursor, copilot, gemini, cline); the row names the `root` that hit
|
|
2632
|
+
plugin an installed+enabled plugin (bare or <plugin>:<name>)
|
|
2633
|
+
builtin-assumed name in HARNESS_BUILTINS -- assumed, harness-provided, NOT measured
|
|
2634
|
+
declared-external the human listed it in defaults.qa_tools_external
|
|
2635
|
+
MISSING none of the above
|
|
2636
|
+
|
|
2637
|
+
For project/global/plugin the SKILL.md (or plugin command) path and its sha256 are returned;
|
|
2638
|
+
builtin-assumed / declared-external / MISSING carry neither (nothing was measured). Tool
|
|
2639
|
+
matching is LITERAL -- `review` and `open-code-review:review` are different names."""
|
|
2640
|
+
home = _claude_home(home)
|
|
2641
|
+
external = set(declared_external or ())
|
|
2642
|
+
ps = os.path.join(project_root, ".claude", "skills", name, "SKILL.md")
|
|
2643
|
+
if os.path.isfile(ps):
|
|
2644
|
+
return {"tool": name, "source": "project-skill", "path": ps,
|
|
2645
|
+
"sha256": _sha256_file(ps)}
|
|
2646
|
+
for target, root in _global_skill_roots(home):
|
|
2647
|
+
gs = os.path.join(root, name, "SKILL.md")
|
|
2648
|
+
if os.path.isfile(gs):
|
|
2649
|
+
return {"tool": name, "source": "global-skill", "root": target, "path": gs,
|
|
2650
|
+
"sha256": _sha256_file(gs)}
|
|
2651
|
+
plug = _resolve_plugin_tool(name, home, cwd=project_root)
|
|
2652
|
+
if plug is not None:
|
|
2653
|
+
return {"tool": name, "source": "plugin", "path": plug[0], "sha256": plug[1]}
|
|
2654
|
+
if name in external:
|
|
2655
|
+
return {"tool": name, "source": "declared-external", "path": None, "sha256": None}
|
|
2656
|
+
if name in HARNESS_BUILTINS:
|
|
2657
|
+
return {"tool": name, "source": "builtin-assumed", "path": None, "sha256": None}
|
|
2658
|
+
return {"tool": name, "source": "MISSING", "path": None, "sha256": None}
|
|
2659
|
+
|
|
2660
|
+
|
|
2661
|
+
QA_LIVE_CONFIG = "uscha.config.json"
|
|
2662
|
+
|
|
2663
|
+
|
|
2664
|
+
def _live_project_config(config, ledger_path):
|
|
2665
|
+
"""The LIVE project config to read, or None: `config` in the cwd, then beside the ledger --
|
|
2666
|
+
the 1.98.0 `_config_beside_ledger` order. The kit's own REFERENCE uscha.config.json is never
|
|
2667
|
+
a project's live config (the 2.0.0 bug class: every knob in it would arrive as a human
|
|
2668
|
+
declaration), so a candidate that IS that file is skipped."""
|
|
2669
|
+
kit = _kit_root()
|
|
2670
|
+
ref = os.path.realpath(os.path.join(kit, QA_LIVE_CONFIG)) if kit else None
|
|
2671
|
+
cands = [config]
|
|
2672
|
+
if ledger_path and not os.path.isabs(config):
|
|
2673
|
+
cands.append(os.path.join(os.path.dirname(os.path.abspath(ledger_path)), config))
|
|
2674
|
+
for cand in cands:
|
|
2675
|
+
if os.path.isfile(cand) and os.path.realpath(cand) != ref:
|
|
2676
|
+
return cand
|
|
2677
|
+
return None
|
|
2678
|
+
|
|
2679
|
+
|
|
2680
|
+
def _effective_qa_external(frozen_cfg, ledger_path, config=QA_LIVE_CONFIG):
|
|
2681
|
+
"""The effective defaults.qa_tools_external -- ONE function for `qa-tools-check`, `log-step`
|
|
2682
|
+
provenance and `doctor` (2.6.0 fresh review, S1: the gate read only the frozen copy while
|
|
2683
|
+
doctor reported the live file, so the two disagreed and the gate's own remediation was a
|
|
2684
|
+
dead end on any initialised ledger).
|
|
2685
|
+
|
|
2686
|
+
The list is a fact about the MACHINE -- which tools are installed -- not a run parameter, so
|
|
2687
|
+
a LIVE declaration wins over the ledger's frozen copy: declaring a tool after `init` takes
|
|
2688
|
+
effect without re-init, and nothing writes the ledger (its integrity hash is untouched).
|
|
2689
|
+
Both copies go through the same `_resolved_defaults` machinery doctor uses. Only this key
|
|
2690
|
+
moves: qa_tools_order stays the frozen run parameter.
|
|
2691
|
+
|
|
2692
|
+
Precedence is PER KEY (2.6.0 review): a live file that DECLARES the key -- an explicit `[]`
|
|
2693
|
+
included, a deliberate withdrawal -- wins; a live file that OMITS it falls back to the frozen
|
|
2694
|
+
copy, then to the default. Strict-live (any live file wins) read an unrelated live config as
|
|
2695
|
+
"nothing declared" and silently discarded the frozen declaration -- a false MISSING block.
|
|
2696
|
+
|
|
2697
|
+
Returns {"value": [...], "origin": "live-config" | "frozen" | "default", "path": file|None}.
|
|
2698
|
+
`default` means declared nowhere; `path` is the live file only when it is the declaring one.
|
|
2699
|
+
A live file that EXISTS but cannot be read or resolved, or that declares the key with the
|
|
2700
|
+
wrong shape, refuses, naming it -- answering "none declared" or falling back to the frozen
|
|
2701
|
+
copy would answer from something other than what the human wrote."""
|
|
2702
|
+
live = _live_project_config(config, ledger_path)
|
|
2703
|
+
if live is not None:
|
|
2704
|
+
cfg = _load(live, what="config", flag="--config")
|
|
2705
|
+
try:
|
|
2706
|
+
resolved, live_origin = _resolved_defaults(cfg)
|
|
2707
|
+
except SystemExit as exc:
|
|
2708
|
+
raise SystemExit("[qa_ledger] %s: cannot resolve defaults.qa_tools_external: %s"
|
|
2709
|
+
% (live, exc))
|
|
2710
|
+
live_declares = live_origin.get("qa_tools_external") == "override"
|
|
2711
|
+
ext = resolved.get("qa_tools_external")
|
|
2712
|
+
if live_declares and not _qa_external_shape_ok(ext):
|
|
2713
|
+
raise SystemExit("[qa_ledger] %s: invalid defaults.qa_tools_external (got %s): it "
|
|
2714
|
+
"must be a list of unique nonempty strings -- refusing rather than "
|
|
2715
|
+
"reading it as none declared" % (live, ascii(ext)))
|
|
2716
|
+
if live_declares:
|
|
2717
|
+
return {"value": list(ext), "origin": "live-config", "path": live}
|
|
2718
|
+
# the live file omits the key: the frozen declaration (or the default) stands
|
|
2719
|
+
resolved, origin = _resolved_defaults(frozen_cfg or {})
|
|
2720
|
+
ext = resolved.get("qa_tools_external")
|
|
2721
|
+
declared = origin.get("qa_tools_external") == "override"
|
|
2722
|
+
return {"value": list(ext) if isinstance(ext, list) else [],
|
|
2723
|
+
"origin": "frozen" if declared else "default", "path": None}
|
|
2724
|
+
|
|
2725
|
+
|
|
2446
2726
|
def cmd_log_step(args):
|
|
2447
2727
|
ledger = _load(args.ledger)
|
|
2448
2728
|
node = _repo_node(ledger, args.repo)
|
|
@@ -2454,6 +2734,16 @@ def cmd_log_step(args):
|
|
|
2454
2734
|
if args.fingerprint:
|
|
2455
2735
|
ids = sorted(x.strip() for x in args.fingerprint.split(",") if x.strip())
|
|
2456
2736
|
fp = hashlib.sha1(("|".join(ids)).encode()).hexdigest()[:12] if ids else None
|
|
2737
|
+
# ADR-051: stamp the step with the resolved SOURCE of its tool (and the SKILL.md / plugin
|
|
2738
|
+
# hash where one was measured). A tool that resolves to MISSING is recorded unverified and
|
|
2739
|
+
# does NOT count toward convergence -- a loop can no longer converge trusting a QA tool that
|
|
2740
|
+
# is not installed. Steps written before 2.6.0 carry no `source`/`unverified`: they still
|
|
2741
|
+
# load and are treated exactly as today (a verified step). The stamp is taken at LOG time: a
|
|
2742
|
+
# step logged while its tool was MISSING stays unverified after the tool is installed --
|
|
2743
|
+
# re-log it. The declared-external list is the LIVE config's when it declares one (S1), and a
|
|
2744
|
+
# declared-external step records which copy declared it.
|
|
2745
|
+
ext = _effective_qa_external(ledger.get("config") or {}, args.ledger)
|
|
2746
|
+
res = resolve_qa_tool(args.tool, ext["value"], project_root=".")
|
|
2457
2747
|
record = {
|
|
2458
2748
|
"n": ledger["step_counter"],
|
|
2459
2749
|
"at": _now(),
|
|
@@ -2470,7 +2760,16 @@ def cmd_log_step(args):
|
|
|
2470
2760
|
"fingerprint": fp,
|
|
2471
2761
|
"finding_ids": ids,
|
|
2472
2762
|
"note": args.note,
|
|
2763
|
+
"source": res["source"],
|
|
2473
2764
|
}
|
|
2765
|
+
if res["sha256"]:
|
|
2766
|
+
record["source_sha256"] = res["sha256"]
|
|
2767
|
+
if res.get("root"):
|
|
2768
|
+
record["source_root"] = res["root"]
|
|
2769
|
+
if res["source"] == "declared-external":
|
|
2770
|
+
record["source_origin"] = ext["origin"]
|
|
2771
|
+
if res["source"] == "MISSING":
|
|
2772
|
+
record["unverified"] = True
|
|
2474
2773
|
node["iterations"].append(record)
|
|
2475
2774
|
ledger["steps"].append({"n": record["n"], "at": record["at"], "kind": "qa-step",
|
|
2476
2775
|
"repo": args.repo, "tool": args.tool,
|
|
@@ -2479,7 +2778,10 @@ def cmd_log_step(args):
|
|
|
2479
2778
|
print(f"[qa_ledger] step #{record['n']} logged: {args.repo}/{args.tool} "
|
|
2480
2779
|
f"iter={args.iteration} reported={args.reported} "
|
|
2481
2780
|
f"gated={args.gated_reported} fixed={args.fixed} "
|
|
2482
|
-
f"tests_passed={record['tests_passed']} files_changed={args.files_changed}"
|
|
2781
|
+
f"tests_passed={record['tests_passed']} files_changed={args.files_changed} "
|
|
2782
|
+
f"source={res['source']}"
|
|
2783
|
+
+ ("" if res["source"] != "MISSING" else
|
|
2784
|
+
" (unverified: tool not installed -- does not count toward convergence)"))
|
|
2483
2785
|
|
|
2484
2786
|
|
|
2485
2787
|
def cmd_ingest_gate(args):
|
|
@@ -3634,13 +3936,25 @@ def _converged(node, k, qa_order=None):
|
|
|
3634
3936
|
reasons = []
|
|
3635
3937
|
k = max(1, k)
|
|
3636
3938
|
if qa_order:
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3939
|
+
# ADR-051: a listed tool is satisfied only by a step that both EXISTS and RESOLVED. A
|
|
3940
|
+
# step whose tool was MISSING at log time (unverified) ran a QA tool that is not
|
|
3941
|
+
# installed, so it can no longer stand in for the tool -- it is reported on its own line,
|
|
3942
|
+
# distinct from "never ran". Steps written before 2.6.0 have no `unverified` key, so
|
|
3943
|
+
# `.get` is falsy and they are verified exactly as today (old-ledger compatibility).
|
|
3944
|
+
latest_seen, latest_verified = {}, {}
|
|
3945
|
+
for s in agent: # iteration order preserved -> last wins
|
|
3946
|
+
latest_seen[s["tool"]] = s
|
|
3947
|
+
if s.get("unverified") is not True:
|
|
3948
|
+
latest_verified[s["tool"]] = s
|
|
3949
|
+
never = [t for t in qa_order if t not in latest_seen]
|
|
3950
|
+
unverified = [t for t in qa_order
|
|
3951
|
+
if t in latest_seen and t not in latest_verified]
|
|
3952
|
+
if never:
|
|
3953
|
+
reasons.append(f"agent tools never ran: {','.join(never)}")
|
|
3954
|
+
if unverified:
|
|
3955
|
+
reasons.append("agent tools ran unverified (tool MISSING at log time): "
|
|
3956
|
+
f"{','.join(unverified)}")
|
|
3957
|
+
cycle = [latest_verified[t] for t in qa_order if t in latest_verified]
|
|
3644
3958
|
if not agent:
|
|
3645
3959
|
return False, ["no agent steps measured"]
|
|
3646
3960
|
else:
|
|
@@ -3782,6 +4096,17 @@ def _derive_phase(ledger, name, node, k, qa_order):
|
|
|
3782
4096
|
reasons.append("operability: %s -- release, reset and the RUNBOOK are part of done, "
|
|
3783
4097
|
"not of the last week (ADR-048)"
|
|
3784
4098
|
% (_op.get("note") or "checks missing"))
|
|
4099
|
+
# qa-tools gate (ADR-051). NAMING only: a failing gate:qa-tools record is already a BLOCKER
|
|
4100
|
+
# through _gate_open_and_sev and already vetoes convergence, so nothing new is gated here.
|
|
4101
|
+
# What is added is WHICH declared QA tool is not installed -- "static-gate gated=1
|
|
4102
|
+
# (gate:qa-tools:1)" tells a human the gate is red without telling them which tool, and
|
|
4103
|
+
# `phase --require pr-ready` is where that answer is needed.
|
|
4104
|
+
_qt = _latest_static_by_tool(node).get("gate:qa-tools")
|
|
4105
|
+
if _qt and (_qt.get("gated_reported") or 0) > 0:
|
|
4106
|
+
reasons.append("qa tools: %s -- a declared QA tool is not installed; install it or "
|
|
4107
|
+
"declare it in defaults.qa_tools_external, then re-run "
|
|
4108
|
+
"qa-tools-check (ADR-051)"
|
|
4109
|
+
% (_qt.get("note") or "tool(s) missing"))
|
|
3785
4110
|
_cr = _cr_cfg(ledger)
|
|
3786
4111
|
if _cr and _cr.get("mode") == "final":
|
|
3787
4112
|
_head = None
|
|
@@ -4730,6 +5055,82 @@ def cmd_operability(args):
|
|
|
4730
5055
|
sys.exit(0)
|
|
4731
5056
|
|
|
4732
5057
|
|
|
5058
|
+
# --------------------------------------------------------------------------- #
|
|
5059
|
+
# qa-tools-check (ADR-051: a declared QA tool that is not installed is a MEASURED fact)
|
|
5060
|
+
# --------------------------------------------------------------------------- #
|
|
5061
|
+
def cmd_qa_tools_check(args):
|
|
5062
|
+
"""Resolve every tool in the EFFECTIVE qa_tools_order and persist gate:qa-tools.
|
|
5063
|
+
|
|
5064
|
+
Unlike `operability` (always exit 0, the gate is the profile's), this check exits 1 when a
|
|
5065
|
+
tool is MISSING: "a declared tool is not installed" is a fact under EVERY profile (ADR-043:
|
|
5066
|
+
a fact blocks, an opinion does not), so there is no profile knob here. A MISSING tool makes
|
|
5067
|
+
the record a FAIL -- caps readiness <=65 and blocks convergence through the same plumbing
|
|
5068
|
+
every other FACT gate uses. builtin-assumed and declared-external count as present and are
|
|
5069
|
+
reported honestly. With no qa_tools_order declared there is nothing to resolve: UNMEASURED,
|
|
5070
|
+
exit 0, persist nothing -- a list is never synthesized."""
|
|
5071
|
+
ledger = _load(args.ledger)
|
|
5072
|
+
node = _repo_node(ledger, args.repo)
|
|
5073
|
+
resolved, _origin = _resolved_defaults(ledger.get("config") or {})
|
|
5074
|
+
qa_order = resolved.get("qa_tools_order")
|
|
5075
|
+
|
|
5076
|
+
if not qa_order:
|
|
5077
|
+
# NO-ORDER: window convergence, no list. Nothing to resolve.
|
|
5078
|
+
report = {"repo": args.repo, "qa_tools_order": None, "verdict": "unmeasured",
|
|
5079
|
+
"tools": [], "missing": [],
|
|
5080
|
+
"note": "no qa_tools_order declared -- convergence uses a window of agent "
|
|
5081
|
+
"steps; there is no QA-tool list to resolve (UNMEASURED, not a pass)"}
|
|
5082
|
+
if args.json:
|
|
5083
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
5084
|
+
else:
|
|
5085
|
+
print("QA-TOOLS %s: UNMEASURED -- no qa_tools_order declared; nothing to resolve "
|
|
5086
|
+
"(a list is never synthesized)" % args.repo)
|
|
5087
|
+
sys.exit(0)
|
|
5088
|
+
|
|
5089
|
+
ext = _effective_qa_external(ledger.get("config") or {}, args.ledger)
|
|
5090
|
+
results = [resolve_qa_tool(t, ext["value"], project_root=".") for t in qa_order]
|
|
5091
|
+
missing = [r["tool"] for r in results if r["source"] == "MISSING"]
|
|
5092
|
+
note = "; ".join("%s=%s" % (r["tool"], r["source"]) for r in results)
|
|
5093
|
+
rec = _append_gate_record(ledger, node, args.repo, "gate:qa-tools", args.iteration,
|
|
5094
|
+
bool(missing), len(missing) or 1, note, advisory=False)
|
|
5095
|
+
_save(args.ledger, ledger)
|
|
5096
|
+
|
|
5097
|
+
report = {"repo": args.repo, "qa_tools_order": list(qa_order),
|
|
5098
|
+
"verdict": "fail" if missing else "pass", "missing": missing,
|
|
5099
|
+
"tools": results, "note": note, "step": rec["n"],
|
|
5100
|
+
"qa_tools_external": ext}
|
|
5101
|
+
if args.json:
|
|
5102
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
5103
|
+
sys.exit(1 if missing else 0)
|
|
5104
|
+
|
|
5105
|
+
print("QA-TOOLS %s" % args.repo)
|
|
5106
|
+
print(" qa_tools_external = %s (origin: %s%s)"
|
|
5107
|
+
% (", ".join(ext["value"]) or "none declared", ext["origin"],
|
|
5108
|
+
(" " + ext["path"]) if ext["path"] else ""))
|
|
5109
|
+
for r in results:
|
|
5110
|
+
mark = "!!" if r["source"] == "MISSING" else "ok"
|
|
5111
|
+
if r["source"] in ("project-skill", "global-skill", "plugin") and r.get("path"):
|
|
5112
|
+
detail = " -> %s (%s)" % (r["path"], (r["sha256"] or "")[:12])
|
|
5113
|
+
if r.get("root"):
|
|
5114
|
+
detail += " [%s root]" % r["root"]
|
|
5115
|
+
elif r["source"] == "builtin-assumed":
|
|
5116
|
+
detail = " (assumed, harness-provided, not measured)"
|
|
5117
|
+
elif r["source"] == "declared-external":
|
|
5118
|
+
detail = " (declared in defaults.qa_tools_external, %s)" % ext["origin"]
|
|
5119
|
+
else:
|
|
5120
|
+
detail = ""
|
|
5121
|
+
print(" %s %s: %s%s" % (mark, r["tool"], r["source"], detail))
|
|
5122
|
+
if missing:
|
|
5123
|
+
where = ext["path"] or QA_LIVE_CONFIG
|
|
5124
|
+
print(" -- FAIL: %s not installed -- caps readiness <=65 and blocks convergence until "
|
|
5125
|
+
"resolved: install the tool, or, if it is harness-provided, declare it in "
|
|
5126
|
+
"defaults.qa_tools_external of %s and re-run this check (a live declaration wins "
|
|
5127
|
+
"over the ledger's frozen copy; no re-init)" % (", ".join(missing), where))
|
|
5128
|
+
sys.exit(1)
|
|
5129
|
+
print(" -- PASS: every declared QA tool resolves (builtin-assumed / declared-external "
|
|
5130
|
+
"count as present, reported honestly)")
|
|
5131
|
+
sys.exit(0)
|
|
5132
|
+
|
|
5133
|
+
|
|
4733
5134
|
# --------------------------------------------------------------------------- #
|
|
4734
5135
|
# golden-coverage (ADR-006: the golden<->source mapping, DERIVED BY MEASUREMENT)
|
|
4735
5136
|
# --------------------------------------------------------------------------- #
|
|
@@ -14241,8 +14642,25 @@ def cmd_doctor(args):
|
|
|
14241
14642
|
|
|
14242
14643
|
# --- proyecto (si hay config aca) ---------------------------------------
|
|
14243
14644
|
qa_order = ["code-review", "judgment-day", "improve"] # default del kit
|
|
14645
|
+
qa_external = [] # ADR-051, declared by the human
|
|
14244
14646
|
effective, risk_profile = None, None
|
|
14245
14647
|
cfg_path = args.config or "uscha.config.json"
|
|
14648
|
+
# ADR-051 / S1: qa_tools_external through the SAME function the gate and log-step use --
|
|
14649
|
+
# a live declaration wins over the ledger's frozen copy, per key -- so doctor and
|
|
14650
|
+
# `qa-tools-check` can never report a different value or origin for it.
|
|
14651
|
+
frozen_cfg = None
|
|
14652
|
+
if os.path.isfile(args.ledger):
|
|
14653
|
+
try:
|
|
14654
|
+
frozen_cfg = _load(args.ledger).get("config") or {}
|
|
14655
|
+
except SystemExit:
|
|
14656
|
+
frozen_cfg = None # reported by the ledger check below
|
|
14657
|
+
try:
|
|
14658
|
+
qa_ext_info = _effective_qa_external(frozen_cfg, args.ledger, config=cfg_path)
|
|
14659
|
+
except SystemExit as exc:
|
|
14660
|
+
qa_ext_info = None
|
|
14661
|
+
warn("qa_tools_external not resolvable", str(exc))
|
|
14662
|
+
if qa_ext_info is not None:
|
|
14663
|
+
qa_external = qa_ext_info["value"]
|
|
14246
14664
|
if os.path.isfile(cfg_path):
|
|
14247
14665
|
try:
|
|
14248
14666
|
cfg = _load(cfg_path)
|
|
@@ -14289,17 +14707,30 @@ def cmd_doctor(args):
|
|
|
14289
14707
|
if origin is not None:
|
|
14290
14708
|
effective = {k: {"value": resolved.get(k), "origin": origin[k]}
|
|
14291
14709
|
for k in ENGINE_DEFAULTS}
|
|
14710
|
+
if qa_ext_info is not None:
|
|
14711
|
+
# the one key read through _effective_qa_external (S1): its origin axis is
|
|
14712
|
+
# live-config / frozen / default, exactly what `qa-tools-check` reports
|
|
14713
|
+
effective["qa_tools_external"] = {"value": qa_ext_info["value"],
|
|
14714
|
+
"origin": qa_ext_info["origin"]}
|
|
14715
|
+
else:
|
|
14716
|
+
# unresolvable (warned above, naming why): ABSENT, never the raw value under
|
|
14717
|
+
# an `override` origin its live-config / frozen / default axis does not have
|
|
14718
|
+
effective.pop("qa_tools_external", None)
|
|
14292
14719
|
ok("risk profile: %s" % (risk_profile or "none declared"),
|
|
14293
14720
|
"effective settings below - precedence: override > profile > default")
|
|
14294
14721
|
for key in ENGINE_DEFAULTS:
|
|
14295
|
-
|
|
14296
|
-
|
|
14722
|
+
if key not in effective:
|
|
14723
|
+
continue
|
|
14724
|
+
value = effective[key]["value"]
|
|
14725
|
+
if isinstance(value, list) and value:
|
|
14297
14726
|
shown = ", ".join(value)
|
|
14298
|
-
elif value is None:
|
|
14727
|
+
elif value is None or value == []:
|
|
14299
14728
|
shown = ENGINE_DEFAULT_NOTES.get(key, "not declared")
|
|
14300
14729
|
else:
|
|
14301
14730
|
shown = value
|
|
14302
|
-
detail = "origin: " +
|
|
14731
|
+
detail = "origin: " + effective[key]["origin"]
|
|
14732
|
+
if key == "qa_tools_external" and qa_ext_info and qa_ext_info["path"]:
|
|
14733
|
+
detail += " " + qa_ext_info["path"]
|
|
14303
14734
|
if (origin[key] == "override" and risk_profile
|
|
14304
14735
|
and key in RISK_PROFILES.get(risk_profile, {})):
|
|
14305
14736
|
detail += (" - this override supersedes profile %s (information: an "
|
|
@@ -14355,17 +14786,22 @@ def cmd_doctor(args):
|
|
|
14355
14786
|
"as amended) - only needed to RUN the loop here")
|
|
14356
14787
|
|
|
14357
14788
|
# --- 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
|
-
|
|
14789
|
+
# sin ellas la fase 3 (QA loop) no corre; chequeables con o sin config. ADR-051: resolved
|
|
14790
|
+
# through the SAME six-source resolver `qa-tools-check` uses, so a plugin/builtin-assumed/
|
|
14791
|
+
# declared-external tool no longer false-warns here (code-review used to). doctor stays a
|
|
14792
|
+
# WARN-only lens (exit code unchanged): the blocking gate is `qa-tools-check`. A MISSING
|
|
14793
|
+
# tool is a warn naming it and pointing at that subcommand.
|
|
14794
|
+
qa_res = [resolve_qa_tool(t, qa_external, project_root=".") for t in qa_order]
|
|
14795
|
+
missing_qa = [r["tool"] for r in qa_res if r["source"] == "MISSING"]
|
|
14362
14796
|
if not missing_qa:
|
|
14363
|
-
|
|
14797
|
+
shown = ", ".join("%s (%s)" % (r["tool"], r["source"]) for r in qa_res)
|
|
14798
|
+
ok(f"QA tools del loop resueltas: {shown}")
|
|
14364
14799
|
else:
|
|
14365
|
-
warn(f"
|
|
14366
|
-
"install your
|
|
14367
|
-
"
|
|
14368
|
-
"
|
|
14800
|
+
warn(f"QA tools no instaladas: {', '.join(missing_qa)}",
|
|
14801
|
+
"install them in your agent's skills root (~/.claude/skills/, ~/.agents/skills/, "
|
|
14802
|
+
"... or a project .claude/skills/), enable a plugin that provides them, or "
|
|
14803
|
+
"declare them in config.defaults.qa_tools_external if they are harness-provided "
|
|
14804
|
+
"-- `qa-tools-check` is the gate that BLOCKS on this fact")
|
|
14369
14805
|
|
|
14370
14806
|
# --- veredicto ----------------------------------------------------------
|
|
14371
14807
|
n_ok = sum(1 for lv, _, _ in checks if lv == "ok")
|
|
@@ -14806,6 +15242,15 @@ def build_parser():
|
|
|
14806
15242
|
pop.add_argument("--iteration", type=int, default=1)
|
|
14807
15243
|
pop.add_argument("--json", action="store_true")
|
|
14808
15244
|
pop.set_defaults(func=cmd_operability)
|
|
15245
|
+
pqt = sub.add_parser("qa-tools-check",
|
|
15246
|
+
help="resolve every tool in the EFFECTIVE qa_tools_order and persist "
|
|
15247
|
+
"gate:qa-tools (ADR-051); exit 1 when a declared QA tool is not "
|
|
15248
|
+
"installed -- a fact that blocks under every profile")
|
|
15249
|
+
add_ledger(pqt)
|
|
15250
|
+
pqt.add_argument("--repo", required=True)
|
|
15251
|
+
pqt.add_argument("--iteration", type=int, default=1)
|
|
15252
|
+
pqt.add_argument("--json", action="store_true")
|
|
15253
|
+
pqt.set_defaults(func=cmd_qa_tools_check)
|
|
14809
15254
|
pre = sub.add_parser("resolve-escalation",
|
|
14810
15255
|
help="close open escalations for a repo (recorded event; "
|
|
14811
15256
|
"lifts the readiness cap)")
|
|
@@ -14832,7 +15277,9 @@ def build_parser():
|
|
|
14832
15277
|
"it. smoke (ADR-047) is the same door for a smoke run measured "
|
|
14833
15278
|
"elsewhere -- a FACT kind, so advisory is refused on it. "
|
|
14834
15279
|
"operability (ADR-048) is accepted for parity with the "
|
|
14835
|
-
"`operability` subcommand, which is what normally writes it"
|
|
15280
|
+
"`operability` subcommand, which is what normally writes it. "
|
|
15281
|
+
"gate:qa-tools (ADR-051) has NO door here: the engine measures it "
|
|
15282
|
+
"itself, and only `qa-tools-check` writes it")
|
|
14836
15283
|
plg.add_argument("--verdict", required=True,
|
|
14837
15284
|
choices=["pass", "fail", "advisory", "not-run"],
|
|
14838
15285
|
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.7.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.7.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.7.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.7.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
|
|