@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.
Files changed (31) hide show
  1. package/README.md +14 -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 +469 -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/INSTALL.md +7 -0
  16. package/uscha-kit/README.md +49 -3
  17. package/uscha-kit/VERSION +1 -1
  18. package/uscha-kit/WORKBENCH.md +1 -1
  19. package/uscha-kit/install-uscha.py +215 -2
  20. package/uscha-kit/skills/uscha-adr-refine/SKILL.md +1 -1
  21. package/uscha-kit/skills/uscha-characterize/SKILL.md +1 -1
  22. package/uscha-kit/skills/uscha-devloop/SKILL.md +35 -1
  23. package/uscha-kit/skills/uscha-devloop/qa_ledger.py +469 -22
  24. package/uscha-kit/skills/uscha-discovery/SKILL.md +1 -1
  25. package/uscha-kit/skills/uscha-mirador/SKILL.md +1 -1
  26. package/uscha-kit/skills/uscha-reverse-discovery/SKILL.md +1 -1
  27. package/uscha-kit/skills/uscha-rubric/SKILL.md +1 -1
  28. package/uscha-kit/skills/uscha-status/SKILL.md +1 -1
  29. package/uscha-kit/skills/uscha-sysdoc/SKILL.md +1 -1
  30. package/uscha-kit/templates/CONSTITUTION.md +14 -0
  31. package/uscha-kit/uscha.config.json +2 -1
@@ -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.7.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.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
 
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "uscha",
4
- "version": "2.5.0",
4
+ "version": "2.7.0",
5
5
  "displayName": "Uscha",
6
- "description": "Spec-driven development for LLM coding agents: 9 skills (discovery, adr-refine, reverse-discovery, characterize, devloop, sysdoc, rubric, mirador, status) + a stdlib measurement engine (qa_ledger.py, 56 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
6
+ "description": "Spec-driven development for LLM coding agents: 9 skills (discovery, adr-refine, reverse-discovery, characterize, devloop, sysdoc, rubric, mirador, status) + a stdlib measurement engine (qa_ledger.py, 57 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
7
7
  "author": {
8
8
  "name": "Andres Massello",
9
9
  "url": "https://github.com/andresmassello"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uscha",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "description": "Uscha spec-driven development methodology for coding agents. Includes npm/npx router.",
5
5
  "author": {
6
6
  "name": "Andres Massello",
@@ -123,6 +123,13 @@ npx --yes @andresmassello/uscha@latest doctor --target both
123
123
 
124
124
  `doctor` exits 1 for any unhealthy target in either text or `--json` mode. It checks installed skill presence, manifest/marketplace or hook registration, marker, and version; it does not measure file-content integrity.
125
125
 
126
+ `install` (after its transaction commits) and `doctor` (as an advisory section) also report the
127
+ extras (ADR-052, kit 2.7.0): how the dev loop's QA tools resolve on the target machine and whether
128
+ the optional engram is present, with the commands to add it. engram is present when it is usable
129
+ from Claude (the plugin installed and enabled, or an `engram` MCP server in the Claude
130
+ configuration); a bare binary on PATH reads "binary found, not wired into Claude". They only
131
+ report: nothing is installed, no command is run, and neither exit code changes.
132
+
126
133
  If `npm view` returns `404` immediately after a new release, wait a few minutes:
127
134
  npm search/dist-tags can propagate before the package metadata endpoint used by
128
135
  `npx`. Do not republish the same version while propagation is in progress.
@@ -1,6 +1,6 @@
1
1
  # uscha-kit
2
2
 
3
- **Kit version:** v2.5.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
3
+ **Kit version:** v2.7.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
4
4
 
5
5
  Spec-driven orchestrator + multi-repo QA for Claude Code, with a deterministic ledger.
6
6
  **Nine skills** (`uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`,
@@ -171,6 +171,34 @@ engine's default and `true` on presets **C, D and E**:
171
171
  Nothing is graded. The engine can see that a `## Rollback` heading exists; whether the procedure
172
172
  under it is correct is yours.
173
173
 
174
+ ## QA-tool readiness — `qa-tools-check` (ADR-051)
175
+
176
+ Phase 3 runs QA tools the kit ORCHESTRATES but does not ship (`qa_tools_order`, default
177
+ `code-review → judgment-day → improve`). Nothing used to check they were installed — a loop could
178
+ converge trusting a tool that never ran. Now each tool is RESOLVED to a source:
179
+
180
+ ```bash
181
+ python3 $QL qa-tools-check --repo <name> [--json] # exit 1 when a tool is MISSING
182
+ ```
183
+
184
+ | Source | What it means |
185
+ |---|---|
186
+ | `project-skill` / `global-skill` | `./.claude/skills/<name>/SKILL.md`, or `<root>/<name>/SKILL.md` under any agent skills root the installer knows — `~/.claude/skills` (or `$CLAUDE_CONFIG_DIR/skills`), Codex `~/plugins/uscha/skills`, pi `~/.agents/skills`, cursor, copilot, gemini, cline (path + sha256 + the root that hit reported) |
187
+ | `plugin` | an installed **and enabled** plugin provides it (skill or command), bare or `<plugin>:<name>` |
188
+ | `builtin-assumed` | the name is a harness built-in (`code-review`) — assumed, reported honestly as *not measured*, never a false clean and never a block |
189
+ | `declared-external` | you listed it in `defaults.qa_tools_external` (a human declaration, never profile-owned; precedence is per key: a LIVE `uscha.config.json` that declares it — an explicit `[]` included, which withdraws it — wins over the ledger's frozen copy, so declaring it after `init` needs no re-init, while a live file that omits it leaves the frozen declaration standing — origin `live-config` / `frozen` / `default`, the same in `doctor`; a live value of the wrong shape is refused by name) |
190
+ | `MISSING` | none of the above |
191
+
192
+ Only **MISSING** blocks, under **every** profile: a `missing` tool persists `gate:qa-tools` as a
193
+ BLOCKER — readiness capped ≤ 65, convergence blocked, and `phase --require pr-ready` refuses
194
+ **naming** the tool. "A declared tool is not installed" is a fact, not an opinion, so there is no
195
+ profile knob; `qa-tools` is a FACT kind written only by `qa-tools-check` — there is no
196
+ `log-gate --kind qa-tools` door (refused, exit 2), because the engine measures this fact itself.
197
+ With no `qa_tools_order` declared there is nothing to resolve — UNMEASURED, never a pass.
198
+ `log-step` stamps every step with its tool's resolved source; a MISSING step is recorded
199
+ `unverified` and cannot satisfy convergence — and stays so after you install the tool: re-log it.
200
+ Ledgers written before 2.6.0 carry no provenance and load unchanged.
201
+
174
202
  ## Evidence origin (ADR-007) - green, but green at *what*?
175
203
 
176
204
  Freshness compares file mtimes, so the ledger could say "tests green" without being able to
@@ -470,6 +498,21 @@ Claude Code's native plugin flow remains available for Claude-only users, but
470
498
 
471
499
 
472
500
 
501
+ **The extras report** (kit 2.7.0, ADR-052). After the install transaction commits, `install`
502
+ reports — and only reports — how the dev loop's QA tools resolve on the target machine (the
503
+ kit default order `code-review`, `judgment-day`, `improve`, through the engine's own
504
+ `resolve_qa_tool`; `code-review` is "assumed, harness-provided, not measured") and whether the
505
+ optional engram is present, printing the commands a human may run to add it. Present means
506
+ usable from Claude: the plugin installed and enabled (its install path on disk), or an MCP server
507
+ named exactly `engram` at machine level (the user-level `mcpServers` of `.claude.json` or the
508
+ plugin's own `.mcp.json`, never another project's entry). The binary on PATH is reported but
509
+ alone reads "binary found, not wired into Claude". The MCP check reads only the Claude
510
+ configuration, for `--target codex` too. It installs nothing beyond the
511
+ kit, shows no prompt, copies no QA skill and runs none of those commands; a detection error
512
+ prints `could not check extras: <reason>` and the install still succeeds. `--dry-run` lists the
513
+ checks without making them, and `install-uscha.py doctor` repeats the report, live, as an
514
+ advisory section that never changes its exit code.
515
+
473
516
  **Verify the installation with `doctor`** (kit 1.22.0, in the spirit of flutter doctor —
474
517
  Windows and Linux, ASCII output, exit 1 only on errors):
475
518
 
@@ -553,7 +596,10 @@ python3 ~/.claude/skills/uscha-devloop/qa_ledger.py doctor --json
553
596
  ```
554
597
 
555
598
  `origin` is `override` (you declared it), `profile <X>` (the preset supplied it), or
556
- `default` (the engine's own value). An override that supersedes a profile is reported as
599
+ `default` (the engine's own value). The one exception is `qa_tools_external` (ADR-051): it is
600
+ never profile-owned, so its origin is `live-config` (the live `uscha.config.json` declares
601
+ it), `frozen` (only the ledger's frozen copy does) or `default` (declared nowhere) — or it is absent
602
+ from `effective`, with a warn naming why, when the live value cannot be resolved. An override that supersedes a profile is reported as
557
603
  information, not an error — declaring a knob by hand is how a preset is bent. Nothing is
558
604
  written back into your config: a knob nobody declared stays absent, which is what lets the
559
605
  engine keep telling a requirement apart from a default. With no profile and no
@@ -876,7 +922,7 @@ python3 $QL readiness
876
922
 
877
923
  ## Ledger subcommands
878
924
 
879
- `bench - bench-curate - bench-r2 - bench-roundtrip - bootstrap-oracle - bootstrap-variance - check-coverage - check-terminado - cleanroom - compile-ingest - compile-validate - converged - corpus-run - curate - curation-check - dashboard - discover - doctor - escalate - execution-policy - facts - fastpath-eval - fidelity - flag-blocker - gate-check - golden-coverage - golden-diff - ingest-gate - init - ir-extract - ir-render - lang-compare - log-gate - log-step - operability - oscillation - phase - pit-check - production-finding - promote - readiness - rebuild - regression-check - resolve-escalation - roundtrip - rubric-ingest - simplicity-check - smoke-ingest - snapshot - spec-change-request - spec-check - spec-doubt - spec-drift - summary - top - waste-check` - the exact current `qa_ledger.py` parser surface (56 subcommands, derived from `SYSTEM-FACTS.json`, itself introspected from `build_parser()`); each supports `--help`.
925
+ `bench - bench-curate - bench-r2 - bench-roundtrip - bootstrap-oracle - bootstrap-variance - check-coverage - check-terminado - cleanroom - compile-ingest - compile-validate - converged - corpus-run - curate - curation-check - dashboard - discover - doctor - escalate - execution-policy - facts - fastpath-eval - fidelity - flag-blocker - gate-check - golden-coverage - golden-diff - ingest-gate - init - ir-extract - ir-render - lang-compare - log-gate - log-step - operability - oscillation - phase - pit-check - production-finding - promote - qa-tools-check - readiness - rebuild - regression-check - resolve-escalation - roundtrip - rubric-ingest - simplicity-check - smoke-ingest - snapshot - spec-change-request - spec-check - spec-doubt - spec-drift - summary - top - waste-check` - the exact current `qa_ledger.py` parser surface (57 subcommands, derived from `SYSTEM-FACTS.json`, itself introspected from `build_parser()`); each supports `--help`.
880
926
 
881
927
  `gate-check --repo R` SCOPES the diff to `repos[R].path` (2.2.0): in a monorepo one `git diff`
882
928
  carries every repo's hunks, and a sibling's findings are neither this repo's report nor this
package/uscha-kit/VERSION CHANGED
@@ -1 +1 @@
1
- uscha-kit 2.5.0
1
+ uscha-kit 2.7.0
@@ -18,7 +18,7 @@ los linters del static gate, drivers) es el *adapter* del proyecto y vive en el
18
18
  | **Python 3.8+** | corre `qa_ledger.py` (stdlib pura, sin dependencias) | `python3` en PATH |
19
19
  | **git** | versionado | 2.x, con `user.name`/`user.email` |
20
20
  | **gh** (GitHub CLI) | crear repo / abrir PR | opcional pero recomendado |
21
- | **skills del kit (8)** | `uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`, `uscha-characterize`, `uscha-rubric`, `uscha-mirador` | instalados por `npx` en Codex y/o Claude Code |
21
+ | **skills del kit (9)** | `uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`, `uscha-characterize`, `uscha-rubric`, `uscha-mirador`, `uscha-status` | instalados por `npx` en Codex y/o Claude Code |
22
22
  | **skills de QA** | `code-review`, `judgment-day`, `improve` | tus skills globales (el dev-loop los **orquesta**, no los trae) |
23
23
 
24
24
  ---
@@ -618,18 +618,231 @@ def cmd_install(args):
618
618
  else:
619
619
  root = installers[target](home, args.mode, args.dry_run, operations)
620
620
  installed[target] = str(root)
621
- emit({"status": "planned" if args.dry_run else "installed", "dry_run": args.dry_run, "source_version": source_version(), "home": str(home), "installed": installed, "operations": operations, "next": next_steps(args.target)}, args.json)
621
+ # ADR-052: the extras report runs only AFTER every install transaction above has committed
622
+ # (an install that raised never gets here, so its exit code is untouched), and it can only
623
+ # ever ADD lines -- a detection error is reported, never raised.
624
+ extras = extras_plan() if args.dry_run else detect_extras(home, explicit_home=bool(args.home))
625
+ emit({"status": "planned" if args.dry_run else "installed", "dry_run": args.dry_run, "source_version": source_version(), "home": str(home), "installed": installed, "operations": operations, "next": next_steps(args.target), "extras": extras}, args.json)
626
+ if not args.json:
627
+ print_extras(extras)
622
628
 
623
629
 
624
630
  def cmd_doctor(args):
625
631
  home = home_path(args)
626
632
  targets = {target: target_status(home, target) for target in selected_targets(args.target)}
627
633
  ok = all(status["healthy"] for status in targets.values())
628
- emit({"ok": ok, "source_version": source_version(), "home": str(home), "python": sys.version.split()[0], "targets": targets}, args.json)
634
+ # Advisory, LIVE and stateless (ADR-052): re-detected on every run through the same functions
635
+ # the install summary uses, never read from the marker, and never part of `ok`.
636
+ extras = detect_extras(home, explicit_home=bool(args.home))
637
+ emit({"ok": ok, "source_version": source_version(), "home": str(home), "python": sys.version.split()[0], "targets": targets, "extras": extras}, args.json)
638
+ if not args.json:
639
+ print_extras(extras, advisory=True)
629
640
  if not ok:
630
641
  raise SystemExit(1)
631
642
 
632
643
 
644
+ # --------------------------------------------------------------------------------------------- #
645
+ # Optional extras (ADR-052, kit 2.7.0) -- DETECT AND TELL, never install.
646
+ # The dev loop orchestrates QA tools the kit does not ship (code-review / judgment-day / improve)
647
+ # and works with an optional memory plugin (engram). The installer REPORTS whether they are
648
+ # present and prints the commands a human may choose to run. It installs nothing beyond the kit,
649
+ # asks nothing, copies no QA skill and runs none of the commands it prints: the default install
650
+ # stays dependency-free. Tool resolution is NOT reimplemented here -- the engine's own
651
+ # resolve_qa_tool (ADR-051) answers, so `qa-tools-check` and this report cannot disagree.
652
+ # --------------------------------------------------------------------------------------------- #
653
+ EXTRAS_ENGINE = KIT_ROOT / ".claude" / "skills" / "uscha-devloop" / "qa_ledger.py"
654
+ QA_ORDER_NOTE = "the kit default order; your project's risk profile may need fewer"
655
+ QA_SOURCE_NOTES = {"builtin-assumed": "assumed, harness-provided, not measured"}
656
+ ENGRAM_NOTE = "optional -- uscha does not require it"
657
+ ENGRAM_CHECKS = (("binary", "binary on PATH"),
658
+ ("plugin", "plugin installed and enabled"),
659
+ ("mcp", "MCP server in the Claude config"))
660
+ # Printed for a human to run; this installer never executes them (smoke T170 pins that).
661
+ ENGRAM_ADD_COMMANDS = ("claude plugin marketplace add Gentleman-Programming/engram",
662
+ "claude plugin install engram",
663
+ "engram setup claude-code")
664
+ ENGRAM_BINARY_NOTE = "The engram binary itself is a separate download."
665
+ ENGRAM_NOT_WIRED = "binary found, not wired into Claude"
666
+ # The MCP server name the engram plugin's own `.mcp.json` declares. Matched EXACTLY, never as a
667
+ # substring: a server called `not-an-engramatic-thing` is not engram.
668
+ ENGRAM_MCP_SERVER = "engram"
669
+ # A directory that is never created: the resolver's PROJECT probes (./.claude/skills, the project
670
+ # settings files) are pointed at it, so the report describes the MACHINE, not whatever directory
671
+ # `npx` happened to be run from.
672
+ MACHINE_SCOPE_DIR = ".uscha-machine-scope"
673
+
674
+
675
+ def _extras_engine():
676
+ # the npm package's own engine, imported, never reimplemented; no bytecode is written into
677
+ # the package (the bench arms import it the same way)
678
+ sys.dont_write_bytecode = True
679
+ import importlib.util
680
+ spec = importlib.util.spec_from_file_location("uscha_extras_engine", str(EXTRAS_ENGINE))
681
+ mod = importlib.util.module_from_spec(spec)
682
+ spec.loader.exec_module(mod)
683
+ return mod
684
+
685
+
686
+ def kit_default_qa_order():
687
+ """The kit's default qa_tools_order, read from its reference config -- one list, not two."""
688
+ cfg = load_json(KIT_ROOT / "uscha.config.json", "kit reference config")
689
+ order = (cfg.get("defaults") or {}).get("qa_tools_order")
690
+ if not isinstance(order, list) or not order or not all(isinstance(t, str) for t in order):
691
+ raise InstallError("kit reference config has no qa_tools_order list")
692
+ return order
693
+
694
+
695
+ def extras_plan():
696
+ """--dry-run: list what would be checked; check nothing, write nothing."""
697
+ try:
698
+ order = kit_default_qa_order()
699
+ except Exception as exc:
700
+ return {"checked": False, "error": "%s: %s" % (type(exc).__name__, exc)}
701
+ return {"checked": False, "planned": True,
702
+ "qa_tools": {"order": order, "order_source": QA_ORDER_NOTE},
703
+ "engram": {"note": ENGRAM_NOTE, "checks": [label for _, label in ENGRAM_CHECKS]}}
704
+
705
+
706
+ def _mcp_names(data):
707
+ servers = data.get("mcpServers") if isinstance(data, dict) else None
708
+ return [str(k) for k in servers] if isinstance(servers, dict) else []
709
+
710
+
711
+ def _engram_mcp_wired(eng, home, installs):
712
+ """Is an engram MCP server wired into Claude at MACHINE level? Two sources only: the user-level
713
+ `mcpServers` of `.claude.json`, and the `.mcp.json` of an installed AND enabled engram plugin
714
+ whose installPath exists (that is how the plugin wires its server; `installs` arrives already
715
+ filtered). A `projects[<path>].mcpServers` entry serves one OTHER project, not this machine,
716
+ so it never counts. The Claude config is the only one read, whatever the install --target.
717
+ The name must equal ENGRAM_MCP_SERVER. A missing or malformed file reads as not wired."""
718
+ claude_json = (os.path.join(eng._claude_config_dir(str(home)), ".claude.json")
719
+ if os.environ.get("CLAUDE_CONFIG_DIR")
720
+ else os.path.join(str(home), ".claude.json"))
721
+ names = []
722
+ try:
723
+ with open(claude_json, encoding="utf-8") as fh:
724
+ names.extend(_mcp_names(json.load(fh)))
725
+ except (OSError, ValueError):
726
+ pass
727
+ for _key, ipath in installs:
728
+ try:
729
+ with open(os.path.join(ipath, ".mcp.json"), encoding="utf-8") as fh:
730
+ names.extend(_mcp_names(json.load(fh)))
731
+ except (OSError, ValueError):
732
+ pass
733
+ return ENGRAM_MCP_SERVER in names
734
+
735
+
736
+ def _on_path(name):
737
+ """Is `name` an executable in a PATH entry? A manual walk, NOT shutil.which: on Windows
738
+ shutil.which also searches the CURRENT directory (Python 3.8 always; later versions unless
739
+ NoDefaultCurrentDirectoryInExePath is set), so an `engram.cmd` lying in the directory `npx`
740
+ ran from read as installed. The cwd is searched only when PATH itself names it: a literal `.`,
741
+ or (POSIX semantics) an empty entry -- an empty entry on Windows, a trailing `;`, is skipped.
742
+ On Windows the PATHEXT extensions are tried."""
743
+ if os.name == "nt":
744
+ pathext = os.environ.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD"
745
+ exts = [e for e in pathext.split(os.pathsep) if e]
746
+ if os.path.splitext(name)[1].lower() in [e.lower() for e in exts]:
747
+ names = [name]
748
+ else:
749
+ names = [name + e for e in exts]
750
+ else:
751
+ names = [name]
752
+ for entry in os.environ.get("PATH", "").split(os.pathsep):
753
+ if not entry:
754
+ if os.name == "nt":
755
+ continue
756
+ entry = os.curdir
757
+ for n in names:
758
+ cand = os.path.join(entry, n)
759
+ if os.path.isfile(cand) and (os.name == "nt" or os.access(cand, os.X_OK)):
760
+ return True
761
+ return False
762
+
763
+
764
+ def detect_extras(home, explicit_home=False):
765
+ """The one detection path for the install summary AND doctor. Reads external state only and
766
+ never raises: any error becomes {"checked": False, "error": reason}."""
767
+ saved = os.environ.get("CLAUDE_CONFIG_DIR")
768
+ try:
769
+ # An explicit --home names the machine to describe; a CLAUDE_CONFIG_DIR inherited from
770
+ # the operator's own shell points at THEIR config, so it is set aside for the probe.
771
+ # Without --home the target IS this machine, and the variable is honoured, exactly as
772
+ # the harness and qa-tools-check honour it.
773
+ if explicit_home and saved is not None:
774
+ del os.environ["CLAUDE_CONFIG_DIR"]
775
+ order = kit_default_qa_order()
776
+ eng = _extras_engine()
777
+ scope = os.path.join(str(home), MACHINE_SCOPE_DIR)
778
+ tools = [eng.resolve_qa_tool(t, (), project_root=scope, home=str(home)) for t in order]
779
+ # an enabled record whose installPath is gone is a stale registry entry, not a plugin
780
+ # Claude can load (filtered here, not in the shared engine helper)
781
+ installs = [(k, p) for k, p in
782
+ eng.enabled_plugin_installs(str(home), cwd=scope, plugin="engram")
783
+ if os.path.isdir(p)]
784
+ signals = {"binary": _on_path("engram"),
785
+ "plugin": bool(installs),
786
+ "mcp": _engram_mcp_wired(eng, home, installs)}
787
+ # present = USABLE from Claude: the plugin or the MCP server. The binary alone is
788
+ # informational -- a binary nobody wired in gives Claude no engram.
789
+ usable = bool(signals["plugin"] or signals["mcp"])
790
+ return {"checked": True,
791
+ "claude_config_dir": eng._claude_config_dir(str(home)),
792
+ "qa_tools": {"order": order, "order_source": QA_ORDER_NOTE, "tools": tools},
793
+ "engram": {"note": ENGRAM_NOTE, "present": usable,
794
+ "signals": signals,
795
+ "add_commands": list(ENGRAM_ADD_COMMANDS),
796
+ "binary_note": ENGRAM_BINARY_NOTE}}
797
+ except (Exception, SystemExit) as exc:
798
+ return {"checked": False, "error": "%s: %s" % (type(exc).__name__, exc)}
799
+ finally:
800
+ if explicit_home and saved is not None:
801
+ os.environ["CLAUDE_CONFIG_DIR"] = saved
802
+
803
+
804
+ def extras_lines(extras, advisory=False):
805
+ if not extras.get("checked") and not extras.get("planned"):
806
+ return ["could not check extras: %s" % extras.get("error", "unknown error")]
807
+ qa, engram = extras["qa_tools"], extras["engram"]
808
+ if extras.get("planned"):
809
+ return ["Extras -- would be checked after the install (--dry-run checks nothing):",
810
+ " QA tools for the dev loop (%s): %s" % (qa["order_source"], ", ".join(qa["order"])),
811
+ " engram (%s): %s" % (engram["note"], ", ".join(engram["checks"]))]
812
+ lines = ["Extras -- reported only, never installed%s:"
813
+ % (" (advisory: not part of doctor's verdict)" if advisory else ""),
814
+ " QA tools for the dev loop (%s):" % qa["order_source"]]
815
+ for row in qa["tools"]:
816
+ src = row.get("source")
817
+ where = ("(%s) " % row["root"] if row.get("root") else "") + (row.get("path") or "")
818
+ detail = QA_SOURCE_NOTES.get(src) or where.strip()
819
+ lines.append(" %-14s %s%s" % (row.get("tool"), src, (" -- " + detail) if detail else ""))
820
+ if any(row.get("source") == "MISSING" for row in qa["tools"]):
821
+ lines.append(" A MISSING tool blocks the dev loop (qa-tools-check): install it as a skill or "
822
+ "plugin, or declare it in defaults.qa_tools_external.")
823
+ sig = engram["signals"]
824
+ lines.append(" engram (%s): %s" % (engram["note"], "present" if engram["present"] else "missing"))
825
+ lines.append(" " + " | ".join("%s: %s" % (label, "yes" if sig.get(key) else "no")
826
+ for key, label in ENGRAM_CHECKS))
827
+ if not engram["present"]:
828
+ if sig.get("binary"):
829
+ lines.append(" " + ENGRAM_NOT_WIRED)
830
+ lines.append(" To add it, run these yourself (this installer never runs them):")
831
+ lines.extend(" " + c for c in engram["add_commands"])
832
+ if not sig.get("binary"):
833
+ lines.append(" " + engram["binary_note"])
834
+ return lines
835
+
836
+
837
+ def print_extras(extras, advisory=False):
838
+ try:
839
+ lines = extras_lines(extras, advisory)
840
+ except Exception as exc: # a malformed report must not fail a finished install
841
+ lines = ["could not check extras: %s: %s" % (type(exc).__name__, exc)]
842
+ for line in lines:
843
+ print(line)
844
+
845
+
633
846
  # statusline wiring (kit 1.46.0): the progress statusline + its Stop-hook refresher, installed
634
847
  # per-project. Commands are by NAME with forward slashes (Windows eats backslashes in the
635
848
  # statusLine command; absolute paths are brittle across machines).
@@ -19,7 +19,7 @@ phases. **You are NOT a generator. You are an interrogator that distills.** The
19
19
  is in the questions, not in agreeing.
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.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
 
@@ -19,7 +19,7 @@ what the code DOES, mechanically, by running it — never what it should do.** Y
19
19
  the capture harness; you may NOT create, rename, or edit any `.approved` file.
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.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
 
@@ -27,7 +27,7 @@ artifacts; these can block) and **self-reported** agent counts (log-step — nar
27
27
  recorded for the retrospective; a measured red always overrides a narrated green).
28
28
 
29
29
  <!-- uscha:orientation-block:begin -->
30
- <!-- uscha kit: 2.5.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
30
+ <!-- uscha kit: 2.7.0 -- generated region: edit tools/skill-blocks/, then run `python tools/gen-skill-blocks.py` (never this block by hand) -->
31
31
 
32
32
  ## First contact (show ONCE, then never again)
33
33
 
@@ -207,6 +207,34 @@ install `hooks/block-approved-writes.py` as a `PreToolUse` hook in
207
207
  `settings.json`, and add `*.approved.* binary` to `.gitattributes` (ships in
208
208
  `templates/.gitattributes`) so line endings can't lie in the byte-compare.
209
209
 
210
+ ### QA-tool pre-flight (ADR-051; run BEFORE Phase 0a)
211
+
212
+ Phase 3 runs QA tools the kit ORCHESTRATES but does not ship (`qa_tools_order`). Before any
213
+ planning, verify they are actually installed — the devloop never assumed this before, and a loop
214
+ must not converge trusting a QA tool that never ran:
215
+
216
+ ```bash
217
+ python3 $QL qa-tools-check --repo <REPO>
218
+ ```
219
+
220
+ Every tool resolves to a source — a project skill, a global skill under ANY agent root the
221
+ installer knows (`~/.claude/skills` or `$CLAUDE_CONFIG_DIR/skills`, Codex `~/plugins/uscha/skills`,
222
+ pi `~/.agents/skills`, cursor, copilot, gemini, cline — the row names the root that hit), an
223
+ installed+enabled plugin, a harness built-in (`code-review`, assumed and reported honestly as
224
+ *not measured*), a tool you declared in `defaults.qa_tools_external`, or **MISSING**. A MISSING
225
+ tool exits 1 and persists `gate:qa-tools` (readiness cap ≤ 65, convergence blocked,
226
+ `phase --require pr-ready` refuses naming it). If a tool is MISSING, **STOP** and tell the human
227
+ the named tool: install it, enable the plugin that provides it, or declare it in
228
+ `defaults.qa_tools_external` of the project's `uscha.config.json` if it is harness-provided, then
229
+ re-run `qa-tools-check`. A LIVE config that declares this one key wins over the ledger's frozen
230
+ copy (it is a fact about the machine, not a run parameter), so no re-init is needed; a live
231
+ config that omits it leaves the frozen declaration standing, an explicit `[]` withdraws it, and
232
+ a value that is not a list of unique nonempty strings is refused by name; `doctor` and the
233
+ check print the same value and origin (`live-config` / `frozen` / `default`). The gate is written
234
+ ONLY by `qa-tools-check` — `log-gate --kind qa-tools` does not exist. A step logged while its
235
+ tool was MISSING stays `unverified` after the tool is installed: re-log it. With no
236
+ `qa_tools_order` declared the check reports UNMEASURED and persists nothing.
237
+
210
238
  ## Phase 0a — Fast-path check (ADR-003; run FIRST, before planning ceremony)
211
239
 
212
240
  If `defaults.fast_path` exists in config, run the measured classifier before demanding a
@@ -389,6 +417,12 @@ config, so `qa_tools_order` is often absent there and resolves from `defaults.ri
389
417
  project on **profile A runs `code-review` only** — do NOT invoke judgment-day or improve there,
390
418
  and convergence must not wait for them.
391
419
 
420
+ These tools are **resolved, not assumed** (ADR-051): the QA-tool pre-flight already proved each
421
+ one is a project/global skill, an installed+enabled plugin, a harness built-in, or
422
+ declared-external — and `log-step` stamps every step with its tool's resolved `source`. A step
423
+ whose tool is MISSING is recorded `unverified` and cannot satisfy convergence, so this loop never
424
+ closes on a QA pass from a tool that is not installed.
425
+
392
426
  After **each** tool pass:
393
427
 
394
428
  1. Apply only fixes at/above the severity gate. Send the rest to `ISSUES-DEFERRED.md`.