@andresmassello/uscha 2.1.0 → 2.2.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 (32) hide show
  1. package/README.md +21 -4
  2. package/package.json +1 -1
  3. package/uscha-kit/.claude/skills/uscha-adr-refine/SKILL.md +2 -0
  4. package/uscha-kit/.claude/skills/uscha-characterize/SKILL.md +2 -0
  5. package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +141 -8
  6. package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +1946 -74
  7. package/uscha-kit/.claude/skills/uscha-discovery/SKILL.md +58 -4
  8. package/uscha-kit/.claude/skills/uscha-mirador/SKILL.md +2 -0
  9. package/uscha-kit/.claude/skills/uscha-reverse-discovery/SKILL.md +2 -0
  10. package/uscha-kit/.claude/skills/uscha-rubric/SKILL.md +2 -0
  11. package/uscha-kit/.claude/skills/uscha-status/SKILL.md +40 -0
  12. package/uscha-kit/.claude/skills/uscha-sysdoc/SKILL.md +2 -0
  13. package/uscha-kit/.claude-plugin/plugin.json +2 -2
  14. package/uscha-kit/.codex-plugin/plugin.json +1 -1
  15. package/uscha-kit/README.md +229 -5
  16. package/uscha-kit/VERSION +1 -1
  17. package/uscha-kit/install-uscha.py +18 -2
  18. package/uscha-kit/skills/uscha-adr-refine/SKILL.md +2 -0
  19. package/uscha-kit/skills/uscha-characterize/SKILL.md +2 -0
  20. package/uscha-kit/skills/uscha-devloop/SKILL.md +141 -8
  21. package/uscha-kit/skills/uscha-devloop/qa_ledger.py +1946 -74
  22. package/uscha-kit/skills/uscha-discovery/SKILL.md +58 -4
  23. package/uscha-kit/skills/uscha-mirador/SKILL.md +2 -0
  24. package/uscha-kit/skills/uscha-reverse-discovery/SKILL.md +2 -0
  25. package/uscha-kit/skills/uscha-rubric/SKILL.md +2 -0
  26. package/uscha-kit/skills/uscha-status/SKILL.md +40 -0
  27. package/uscha-kit/skills/uscha-sysdoc/SKILL.md +2 -0
  28. package/uscha-kit/templates/CLAUDE.md +16 -0
  29. package/uscha-kit/templates/CONSTITUTION.md +45 -0
  30. package/uscha-kit/templates/docs/adr/README.md +15 -0
  31. package/uscha-kit/templates/scripts/smoke-report-example.json +24 -0
  32. package/uscha-kit/uscha.config.json +6 -1
@@ -36,6 +36,9 @@ Usage (see `--help` on each subcommand):
36
36
  [--ruff reports/ruff.json --mypy reports/mypy.txt]
37
37
  qa_ledger.py log-gate --repo backend-api --iteration 1 --kind golden-diff \
38
38
  --verdict pass|fail|not-run [--count N] [--note "..."]
39
+ qa_ledger.py corpus-run --repo backend-api --corpus corpus.jsonl \
40
+ --command "python -m parser" [--threshold 99] [--ac AC-FIELD-01]
41
+ qa_ledger.py smoke-ingest --repo backend-api --report reports/smoke.json [--json]
39
42
  qa_ledger.py flag-blocker --repo backend-api --kind constitution --note "INV-XX breached" \
40
43
  [--resolve]
41
44
  qa_ledger.py production-finding --repo backend-api --severity HIGH --title "..." --evidence "..."
@@ -50,6 +53,7 @@ Usage (see `--help` on each subcommand):
50
53
  qa_ledger.py gate-check --from-git --base main [--strict] [--json]
51
54
  qa_ledger.py spec-check --spec SPEC.md [--spec ACCEPTANCE.md] [--strict] [--json]
52
55
  qa_ledger.py golden-diff [--dir .] [--labels golden-labels.json] [--json]
56
+ qa_ledger.py operability --repo backend-api [--json] (ADR-048: exit 0 always)
53
57
  """
54
58
 
55
59
  import argparse
@@ -1884,14 +1888,55 @@ GOLDEN_REQUIRED_CEILING = 49 # ADR-002: no approved golden -> NOT READY (does n
1884
1888
  RISK_PROFILES = {
1885
1889
  "A": {"qa_tools_order": ["code-review"]},
1886
1890
  "B": {"qa_tools_order": ["code-review", "improve"]},
1887
- "C": {"qa_tools_order": ["code-review", "judgment-day", "improve"]},
1891
+ "C": {"qa_tools_order": ["code-review", "judgment-day", "improve"],
1892
+ "operability.gate": True},
1888
1893
  "D": {"qa_tools_order": ["code-review", "judgment-day", "improve"],
1889
- "coverage_threshold": 70, "golden_required": True},
1894
+ "coverage_threshold": 70, "golden_required": True,
1895
+ "operability.gate": True},
1890
1896
  "E": {"qa_tools_order": ["code-review", "judgment-day", "improve"],
1891
- "coverage_threshold": 80, "golden_required": True},
1897
+ "coverage_threshold": 80, "golden_required": True,
1898
+ "operability.gate": True},
1892
1899
  }
1893
1900
 
1894
1901
 
1902
+ def _knob_get(mapping, key):
1903
+ """Read a knob out of a defaults-shaped mapping by its (possibly DOTTED) name ->
1904
+ (found, value). Dotted since 2.2.0 (ADR-048): the first profile-owned knob that lives one
1905
+ level down is `defaults.operability.gate`, in the same block shape the config already uses
1906
+ for `simplicity.gate` and `waste.gate`. A non-dict on the way down is NOT a hit --
1907
+ `operability: true` declares nothing this ladder can read, and guessing what it meant is
1908
+ exactly how a preset goes silently inert (the defect ADR-001 was amended for)."""
1909
+ cur = mapping
1910
+ for part in key.split("."):
1911
+ if not isinstance(cur, dict) or part not in cur:
1912
+ return False, None
1913
+ cur = cur[part]
1914
+ return True, cur
1915
+
1916
+
1917
+ def _knob_set(mapping, key, value):
1918
+ """Write a (possibly dotted) knob, creating the intermediate objects. Intermediates are
1919
+ COPIED on the way down: callers hand this shallow copies of `defaults`, and mutating a
1920
+ nested dict in place would reach back into the config the caller promised not to touch.
1921
+ Returns False -- writing nothing -- when something that is not an object already sits on
1922
+ the path: an explicit declaration always wins, even a malformed one, and naming a
1923
+ malformed one is `_validate_init_config`'s job, not this function's."""
1924
+ parts = key.split(".")
1925
+ cur = mapping
1926
+ for part in parts[:-1]:
1927
+ nxt = cur.get(part)
1928
+ if nxt is None:
1929
+ nxt = {}
1930
+ elif isinstance(nxt, dict):
1931
+ nxt = dict(nxt)
1932
+ else:
1933
+ return False
1934
+ cur[part] = nxt
1935
+ cur = nxt
1936
+ cur[parts[-1]] = value
1937
+ return True
1938
+
1939
+
1895
1940
  def _apply_risk_profile(defaults):
1896
1941
  """Expand defaults['risk_profile'] into concrete knobs UNDER any explicit value (explicit
1897
1942
  wins per key). Records the profile-provided keys in defaults['_risk_profile_keys'] so a cap
@@ -1905,8 +1950,9 @@ def _apply_risk_profile(defaults):
1905
1950
  + ", ".join(sorted(RISK_PROFILES)))
1906
1951
  provided = []
1907
1952
  for key, value in RISK_PROFILES[profile].items():
1908
- if key not in defaults: # an explicit declaration always wins
1909
- defaults[key] = list(value) if isinstance(value, list) else value
1953
+ if _knob_get(defaults, key)[0]: # an explicit declaration always wins
1954
+ continue
1955
+ if _knob_set(defaults, key, list(value) if isinstance(value, list) else value):
1910
1956
  provided.append(key)
1911
1957
  defaults["_risk_profile_keys"] = provided
1912
1958
  return defaults
@@ -1931,6 +1977,11 @@ ENGINE_DEFAULTS = {
1931
1977
  "qa_tools_order": None,
1932
1978
  "coverage_threshold": 60,
1933
1979
  "golden_required": False,
1980
+ # ADR-048. The engine's own posture on operability is ADVISORY: `operability` measures and
1981
+ # records on every profile, but only C/D/E turn the record into a gate. A kit that gated
1982
+ # release + reset + RUNBOOK on every project would be the kit's opinion wearing an exit
1983
+ # code -- the same thing ADR-043 refused for the simplicity budget.
1984
+ "operability.gate": False,
1934
1985
  }
1935
1986
  ENGINE_DEFAULT_NOTES = {
1936
1987
  "qa_tools_order": "not declared - convergence uses a window of --tools-per-cycle "
@@ -1941,17 +1992,35 @@ ENGINE_DEFAULT_NOTES = {
1941
1992
  def _resolved_defaults(cfg):
1942
1993
  """(resolved defaults, origin per profile-owned key) for a RAW config -- the effective
1943
1994
  settings `doctor` reports. Origin is `override` (declared in defaults), `profile <X>`, or
1944
- `default`. Read-only: never mutates cfg and never writes anything back."""
1995
+ `default`. Read-only: never mutates cfg and never writes anything back.
1996
+
1997
+ Works over a RAW config (what `doctor` reads) and over the FROZEN one inside a ledger
1998
+ alike: `init` expands the profile before freezing, so on the frozen copy a profile-supplied
1999
+ knob is already sitting in `defaults` and would otherwise read as a human `override`.
2000
+ `_risk_profile_keys` -- written by `_apply_risk_profile` for exactly this reason, and
2001
+ already read this way by the golden cap -- is what tells the two apart (ADR-048).
2002
+
2003
+ It tells them apart only while the value still IS the profile's, and that is the case the
2004
+ fresh review found: a human who edits the FROZEN copy leaves a declaration the profile
2005
+ never made, `_risk_profile_keys` still names the key, and `operability.gate: false` under
2006
+ profile E reported `origin profile E` -- crediting the preset with the opposite of what it
2007
+ supplies. The DECLARATION is therefore read first: a raw value that disagrees with what the
2008
+ profile would have written is an `override`, whoever typed it and whenever."""
1945
2009
  raw = cfg.get("defaults") if isinstance(cfg, dict) else None
1946
2010
  raw = dict(raw) if isinstance(raw, dict) else {}
1947
- declared = set(raw)
1948
2011
  profile = raw.get("risk_profile")
2012
+ from_profile = set(raw.get("_risk_profile_keys") or [])
1949
2013
  expanded = _apply_risk_profile(dict(raw))
1950
2014
  resolved, origin = {}, {}
1951
2015
  for key, fallback in ENGINE_DEFAULTS.items():
1952
- resolved[key] = expanded[key] if key in expanded else fallback
1953
- if key in declared:
2016
+ found, value = _knob_get(expanded, key)
2017
+ resolved[key] = value if found else fallback
2018
+ raw_found, raw_value = _knob_get(raw, key)
2019
+ profile_value = RISK_PROFILES.get(profile, {}).get(key) if profile else None
2020
+ if raw_found and not (key in from_profile and raw_value == profile_value):
1954
2021
  origin[key] = "override"
2022
+ elif key in from_profile and profile:
2023
+ origin[key] = "profile %s" % profile
1955
2024
  elif profile and key in RISK_PROFILES.get(profile, {}):
1956
2025
  origin[key] = "profile %s" % profile
1957
2026
  else:
@@ -2108,7 +2177,96 @@ def _validate_log_step_counts(args):
2108
2177
  raise SystemExit("[qa_ledger] gated-reported cannot exceed reported")
2109
2178
 
2110
2179
 
2180
+ def _add_repo_to_config_file(path, entry, frozen_names):
2181
+ """Mirror the new repo into uscha.config.json when that file IS the source the ledger was
2182
+ frozen from -- same repo names, in the same order. A file that has DRIFTED from the frozen
2183
+ config is somebody else's edit, and it is left alone with the divergence named: silently
2184
+ rewriting a config that no longer matches the ledger would be resolving a conflict the human
2185
+ has not seen. Returns the sentence to print, never raises: the ledger is the source of truth
2186
+ and its write already happened."""
2187
+ if not os.path.isfile(path):
2188
+ return "%s not found -- the ledger's frozen config is the only copy that changed" % path
2189
+ try:
2190
+ with open(path, "r", encoding="utf-8") as fh:
2191
+ cfg = json.load(fh)
2192
+ names = [r.get("name") for r in cfg.get("repos", [])]
2193
+ except (OSError, ValueError, AttributeError, TypeError) as exc:
2194
+ return "%s left untouched (unreadable: %s)" % (path, exc)
2195
+ if names != frozen_names:
2196
+ return ("%s left untouched: its repos %s differ from the ledger's frozen %s, so it is "
2197
+ "not the source this ledger was built from" % (path, names, frozen_names))
2198
+ cfg["repos"].append(dict(entry))
2199
+ tmp = path + ".tmp"
2200
+ try:
2201
+ with open(tmp, "w", encoding="utf-8") as fh:
2202
+ json.dump(cfg, fh, indent=2, ensure_ascii=False)
2203
+ fh.write("\n")
2204
+ os.replace(tmp, path)
2205
+ except OSError as exc:
2206
+ return "%s left untouched (not writable: %s)" % (path, exc)
2207
+ return "%s updated too (it is the source this ledger was frozen from)" % path
2208
+
2209
+
2210
+ def _init_add_repo(args):
2211
+ """Append ONE repo to an EXISTING ledger without resetting it (2.2.0 field fix).
2212
+
2213
+ Until now the only door was re-running `init --config`, which builds a NEW ledger: the step
2214
+ counter, every repo's iterations and every snapshot went back to zero, so adding a second
2215
+ service to a live loop cost the evidence of the first. Editing QA-LEDGER.json by hand is not
2216
+ the workaround either -- `_load` verifies the sha256 that `_save` writes, so a hand edit
2217
+ turns the file into a refusal. That refusal is correct and stays: it is what makes 'measured
2218
+ beats narrated' worth anything. What was missing was a supported door, and this is it.
2219
+
2220
+ What this deliberately does NOT do: touch any existing repo's steps, snapshots or
2221
+ iterations, and re-freeze `defaults`. The config a project started under stays the config it
2222
+ ran under -- only the repo list grows. `_save` re-seals the checksum over the result.
2223
+
2224
+ The new repo starts with NO evidence, and readiness says so rather than hiding it: it enters
2225
+ `facts.static_unmeasured_repos`, and every aggregate that averages over repos reads LOWER
2226
+ until its first snapshot or gate lands. That is an unmeasured repo, never a regression --
2227
+ nothing already measured changed, and `by_repo` proves it entry by entry. Excluding it from
2228
+ the average instead would be the opposite mistake: an aggregate produced by silence."""
2229
+ name = args.add_repo
2230
+ if not _has_text(args.path) or not _has_text(args.type):
2231
+ raise SystemExit("[qa_ledger] init --add-repo needs --path and --type: the engine never "
2232
+ "guesses where a repo lives or how it is built")
2233
+ if name == "integration":
2234
+ raise SystemExit("[qa_ledger] repo name 'integration' is reserved")
2235
+ ledger = _load(args.out)
2236
+ cfg = ledger.get("config") or {}
2237
+ frozen_names = [r.get("name") for r in cfg.get("repos", [])]
2238
+ if name in frozen_names or name in ledger.get("repos", {}):
2239
+ raise SystemExit(
2240
+ "[qa_ledger] repo '%s' already exists in %s -- nothing was written. Adding it twice "
2241
+ "would either duplicate the config entry or reset that repo's steps, which is the "
2242
+ "very loss this flag exists to prevent." % (name, args.out))
2243
+ entry = {"name": name, "type": args.type, "path": args.path}
2244
+ if _has_text(args.test_command):
2245
+ entry["test_command"] = args.test_command
2246
+ cfg.setdefault("repos", []).append(entry)
2247
+ ledger["config"] = cfg
2248
+ # the same node shape `init` writes, so nothing downstream can tell an appended repo from
2249
+ # one that was there since the first run.
2250
+ ledger["repos"][name] = {"type": args.type, "path": args.path,
2251
+ "snapshots": [], "iterations": []}
2252
+ _save(args.out, ledger)
2253
+ cfg_path = args.config or os.path.join(
2254
+ os.path.dirname(os.path.abspath(args.out)), "uscha.config.json")
2255
+ print("[qa_ledger] %s: repo '%s' added (%s at %s) -- %d repos, every existing repo's steps "
2256
+ "untouched, checksum re-sealed"
2257
+ % (args.out, name, args.type, args.path, len(ledger["repos"])))
2258
+ print("[qa_ledger] %s" % _add_repo_to_config_file(cfg_path, entry, frozen_names))
2259
+ print("[qa_ledger] '%s' starts with NO evidence: it reads as UNMEASURED (readiness lists it "
2260
+ "under facts.static_unmeasured_repos) and every repo average reads lower until its "
2261
+ "first snapshot or gate lands. That is an unmeasured repo, not a regression." % name)
2262
+
2263
+
2111
2264
  def cmd_init(args):
2265
+ if getattr(args, "add_repo", None):
2266
+ return _init_add_repo(args)
2267
+ if not _has_text(getattr(args, "config", None)):
2268
+ raise SystemExit("[qa_ledger] init needs --config to create a ledger, or --add-repo "
2269
+ "NAME (with --path and --type) to append one repo to an existing one")
2112
2270
  cfg = _load(args.config, what="config", flag="--config")
2113
2271
  _validate_init_config(cfg)
2114
2272
  defaults = cfg.get("defaults", {})
@@ -2504,6 +2662,10 @@ def _gate_rollup(ledger):
2504
2662
  # at -- the false-clean is the failure mode, not the absence.
2505
2663
  "advisory": bool(rec.get("advisory")),
2506
2664
  "gated": rec.get("gated_reported", 0),
2665
+ # WHERE it was measured (2.2.0, log-gate --ref): a CI verdict without
2666
+ # its run is a claim, and the rollup is where a reader looks first.
2667
+ # Absent on every record that carries none -- never invented.
2668
+ **({"ref": rec["ref"]} if rec.get("ref") else {}),
2507
2669
  "note": rec.get("note")})
2508
2670
  return sorted(gates, key=lambda g: (g["repo"], g["tool"]))
2509
2671
 
@@ -2731,7 +2893,7 @@ def cmd_spec_change_request(args):
2731
2893
 
2732
2894
 
2733
2895
  def _append_gate_record(ledger, node, repo, tool, iteration, failing, count, note,
2734
- advisory=False):
2896
+ advisory=False, ref=None):
2735
2897
  """Append a static-gate-shaped record for a FACT gate so the EXISTING plumbing
2736
2898
  sees it: _gate_open_and_sev feeds the BLOCKER/CRITICAL readiness cap (<=65) and
2737
2899
  _converged refuses while the latest record for the tool is failing. A later
@@ -2755,6 +2917,11 @@ def _append_gate_record(ledger, node, repo, tool, iteration, failing, count, not
2755
2917
  }
2756
2918
  if advisory:
2757
2919
  rec["advisory"] = True
2920
+ if ref:
2921
+ # WHERE the verdict was measured (2.2.0). A CI verdict typed by hand is a claim; the
2922
+ # run id or URL beside it is the receipt, and the ledger is the only place it survives
2923
+ # the conversation that produced it.
2924
+ rec["ref"] = ref
2758
2925
  node["iterations"].append(rec)
2759
2926
  ledger["steps"].append({"n": rec["n"], "at": rec["at"], "kind": "static-gate",
2760
2927
  "repo": repo, "tool": tool, "iteration": iteration})
@@ -2763,12 +2930,26 @@ def _append_gate_record(ledger, node, repo, tool, iteration, failing, count, not
2763
2930
 
2764
2931
  # The only --kind values log-gate accepts with --verdict advisory (ADR-043): the checks whose
2765
2932
  # default mode IS advisory. Every other kind is a FACT gate and records pass/fail/not-run only.
2766
- ADVISORY_CAPABLE_KINDS = ("simplicity", "waste")
2933
+ #
2934
+ # `corpus` (2.2.0, ADR-046) is admitted because `corpus-run` ITSELF runs advisory whenever no
2935
+ # threshold is declared -- the percentage is measured against no adopted budget, so it gates
2936
+ # nothing. Refusing the verdict on this door while the check emits it on the other would give
2937
+ # one fact two incompatible records: the parity door could only spell an unbudgeted run as
2938
+ # `pass`, which is the false clean ADR-043 exists to refuse. Admitting it costs nothing that
2939
+ # matters: with a threshold declared, `corpus` is a FACT gate like any other.
2940
+ #
2941
+ # `operability` (ADR-048) joins them because its posture is PROFILE-DEPENDENT: on A, B or no
2942
+ # profile the four checks are measured and recorded advisory, and only `defaults.operability.gate`
2943
+ # (which C, D and E own) turns the same measurement into a gate. It is a FACT either way -- a
2944
+ # workflow file exists or it does not -- so admitting it here widens WHEN it gates, never WHAT
2945
+ # counts as evidence, which is the line INV-ADVISORY-01 draws.
2946
+ ADVISORY_CAPABLE_KINDS = ("simplicity", "waste", "corpus", "operability")
2767
2947
 
2768
2948
 
2769
2949
  def cmd_log_gate(args):
2770
- """Persist a FACT-gate verdict (golden-diff / gate-check / pit-check / simplicity / regression)
2771
- into the ledger, so 'facts may block' is enforced by the engine, not by goodwill.
2950
+ """Persist a FACT-gate verdict (golden-diff / gate-check / pit-check / simplicity /
2951
+ regression / ci / smoke) into the ledger, so 'facts may block' is enforced by the engine, not by
2952
+ goodwill.
2772
2953
  fail -> BLOCKER record: trips the <=65 readiness cap AND blocks convergence.
2773
2954
  pass -> clean record for the same tool: credits the fix, convergence sees clean.
2774
2955
  advisory -> a MEASURED, non-gating record (kit 2.1.0, ADR-043): zero gated findings, so
@@ -2790,6 +2971,14 @@ def cmd_log_gate(args):
2790
2971
  # an advisory-class dimension (e.g. "semantic") cannot be registered as a gate through
2791
2972
  # this door at all -- the refusal is structural. The smoke suite measures that the
2792
2973
  # vocabulary stays closed; widening it to admit an advisory kind is a red build.
2974
+ #
2975
+ # `ci` and `smoke` (2.2.0) are the additions since ADR-014, and both are FACTS: a pipeline
2976
+ # either went green on a commit or it did not, and the engine can be TOLD that fact with
2977
+ # the run id or URL beside it (--ref); a smoke check either answered or it did not
2978
+ # (ADR-047), which is why neither may run advisory. They are admitted here because they
2979
+ # are measurable, not because they are useful -- an LLM judgment does not become a gate by
2980
+ # being important. Adding a FACT kind is DECLARED in the CONSTITUTION template, which is
2981
+ # where the closed vocabulary is stated to the project rather than only to this parser.
2793
2982
  ledger = _load(args.ledger)
2794
2983
  node = _repo_node(ledger, args.repo)
2795
2984
  tool = f"gate:{args.kind}"
@@ -2816,7 +3005,8 @@ def cmd_log_gate(args):
2816
3005
  f"records pass, fail or not-run", file=sys.stderr)
2817
3006
  sys.exit(2)
2818
3007
  rec = _append_gate_record(ledger, node, args.repo, tool, args.iteration,
2819
- failing, args.count, args.note, advisory=advisory)
3008
+ failing, args.count, args.note, advisory=advisory,
3009
+ ref=getattr(args, "ref", None))
2820
3010
  _save(args.ledger, ledger)
2821
3011
  if advisory:
2822
3012
  state, effect = "ADVISORY (measured, not gating)", (
@@ -2826,7 +3016,554 @@ def cmd_log_gate(args):
2826
3016
  "caps readiness <=65 and blocks convergence")
2827
3017
  else:
2828
3018
  state, effect = "PASS (clean)", "clears the gate for convergence"
2829
- print(f"[qa_ledger] {args.repo}/{tool}: {state} logged — {effect}")
3019
+ print(f"[qa_ledger] {args.repo}/{tool}: {state} logged — {effect}"
3020
+ + (f" [ref {rec['ref']}]" if rec.get("ref") else ""))
3021
+
3022
+
3023
+ # --------------------------------------------------------------------------- #
3024
+ # corpus-run (kit 2.2.0, ADR-046): FIELD TRUTH for greenfield work.
3025
+ #
3026
+ # `characterize`/`golden-diff` answer the brownfield question -- "does the new code still do
3027
+ # what the OLD code did?" -- and they have no answer at all for a system that never had an old
3028
+ # code. In a greenfield project every test payload was INVENTED by the agent that wrote the
3029
+ # code, so a suite can be green over inputs the world never produces. The field report this
3030
+ # subcommand comes from is exactly that shape: a parser passed every test its author wrote and
3031
+ # was wrong; running the REAL corpus moved it from 96.96 % to 99.645 %.
3032
+ #
3033
+ # So the corpus is the missing evidence class: real inputs with their real expected outputs,
3034
+ # run through the real command, scored as a percentage the ledger persists like any other FACT.
3035
+ # It is deterministic (stable case order, per-case timeout) and it refuses rather than guessing:
3036
+ # a corpus that is missing, empty or malformed is exit 2 naming the line, never a silent 0 %.
3037
+ #
3038
+ # The 2.1.0 posture holds (ADR-043): a gate needs an ADOPTED budget. With no threshold declared
3039
+ # anywhere the run is ADVISORY -- the percentage is measured and persisted, and it gates nothing.
3040
+ # The WEIGHT of field truth in the readiness score is deliberately NOT here: adding a `field`
3041
+ # dimension moves every existing project's number, and that is its own ADR.
3042
+ # --------------------------------------------------------------------------- #
3043
+ CORPUS_DEFAULT_TIMEOUT = 30
3044
+ CORPUS_DEFAULT_MAX_MISSES = 5
3045
+
3046
+
3047
+ def _corpus_refuse(msg):
3048
+ """Every corpus refusal is exit 2 and NAMES what it could not read. The failure mode this
3049
+ exists to prevent is a corpus the runner could not parse being scored as 0 % -- an
3050
+ unmeasurable input reported as a measured catastrophe (or, with the arithmetic the other
3051
+ way, as a clean 100 % over zero cases)."""
3052
+ print("[qa_ledger] corpus-run: " + msg, file=sys.stderr)
3053
+ sys.exit(2)
3054
+
3055
+
3056
+ def _corpus_cases(path):
3057
+ """Read a JSONL corpus into an ORDERED list of cases: file order IS run order, so two runs
3058
+ over the same file report the same misses in the same places. One JSON object per line,
3059
+ `input` and `expected` required, `id` optional (a positional id is derived when absent)."""
3060
+ if not os.path.isfile(path):
3061
+ _corpus_refuse("corpus not found: %s -- a corpus that is not there is UNMEASURED, "
3062
+ "never a measured 0 %%" % path)
3063
+ try:
3064
+ with open(path, encoding="utf-8") as fh:
3065
+ raw = fh.read().splitlines()
3066
+ except OSError as exc:
3067
+ _corpus_refuse("corpus unreadable: %s" % exc)
3068
+ cases = []
3069
+ for n, line in enumerate(raw, 1):
3070
+ if not line.strip():
3071
+ continue
3072
+ try:
3073
+ row = json.loads(line)
3074
+ except ValueError as exc:
3075
+ _corpus_refuse("%s line %d is not valid JSON (%s) -- a malformed corpus is "
3076
+ "refused, never scored" % (path, n, exc))
3077
+ if not isinstance(row, dict):
3078
+ _corpus_refuse("%s line %d is a %s, not a JSON object with `input` and `expected`"
3079
+ % (path, n, type(row).__name__))
3080
+ missing = [k for k in ("input", "expected") if k not in row]
3081
+ if missing:
3082
+ _corpus_refuse("%s line %d has no %s key" % (path, n, " and no ".join(missing)))
3083
+ cases.append({"id": str(row["id"]) if row.get("id") is not None
3084
+ else "case-%03d" % (len(cases) + 1),
3085
+ "input": row["input"], "expected": row["expected"]})
3086
+ if not cases:
3087
+ _corpus_refuse("%s holds 0 cases -- an empty corpus is refused: 0/0 is not 100 %%, "
3088
+ "and it is not 0 %% either" % path)
3089
+ return cases
3090
+
3091
+
3092
+ def _corpus_text(value):
3093
+ """A JSON scalar string travels as itself; anything else travels as its JSON encoding.
3094
+ sort_keys so an object payload is byte-stable across runs."""
3095
+ return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False,
3096
+ sort_keys=True)
3097
+
3098
+
3099
+ def _corpus_match(actual, expected):
3100
+ """Trimmed string compare first, then JSON-equal when BOTH sides parse as JSON -- so a
3101
+ command that reorders an object's keys or prints 1.0 for 1 is not a false miss, while a
3102
+ command that prints prose is compared as prose."""
3103
+ exp, act = _corpus_text(expected).strip(), actual.strip()
3104
+ if act == exp:
3105
+ return True
3106
+ try:
3107
+ return json.loads(act) == json.loads(exp)
3108
+ except ValueError:
3109
+ return False
3110
+
3111
+
3112
+ def _corpus_case(command, case, timeout):
3113
+ """Run ONE case: the input on stdin, the trimmed stdout compared to `expected`.
3114
+ Returns (hit, reason, actual). A non-zero exit is a miss (the command did not answer);
3115
+ a case that outruns the timeout is a miss NAMED `timeout`, never an ambiguous hang."""
3116
+ try:
3117
+ p = subprocess.run(command, shell=True, input=_corpus_text(case["input"]),
3118
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
3119
+ encoding="utf-8", errors="replace", timeout=timeout)
3120
+ except subprocess.TimeoutExpired:
3121
+ return False, "timeout", ""
3122
+ except OSError as exc:
3123
+ return False, "command failed to start: %s" % exc, ""
3124
+ actual = (p.stdout or "").strip()
3125
+ if p.returncode != 0:
3126
+ return False, "exit %d" % p.returncode, actual
3127
+ if _corpus_match(actual, case["expected"]):
3128
+ return True, None, actual
3129
+ return False, "mismatch", actual
3130
+
3131
+
3132
+ def _corpus_num(value):
3133
+ """90.0 reads back as 90 and 99.5 as 99.5 -- the declared budget renders as the human
3134
+ typed it, in the ledger and on every surface that reads the record."""
3135
+ if value is None:
3136
+ return None
3137
+ return int(value) if float(value).is_integer() else float(value)
3138
+
3139
+
3140
+ def _corpus_clip(text, n=120):
3141
+ text = str(text).replace("\n", "\\n")
3142
+ return text if len(text) <= n else text[:n] + "..."
3143
+
3144
+
3145
+ def cmd_corpus_run(args):
3146
+ """Run a REAL-INPUT corpus against a command and persist the result as `gate:corpus`.
3147
+
3148
+ The threshold is the project's, never the kit's: --threshold, else
3149
+ repos[R].corpus_threshold, else defaults.corpus_threshold. With none of the three the run
3150
+ is ADVISORY -- measured, persisted, and gating nothing (ADR-043: a gate needs an adopted
3151
+ budget). With one, `pass` is a clean gate and `fail` is a BLOCKER through the SAME record
3152
+ shape gate-check uses: readiness capped <=65, convergence blocked, and a later green run
3153
+ clears it (latest-per-tool wins).
3154
+
3155
+ --ac stamps criterion ids on the record. A criterion whose only evidence is a corpus record
3156
+ closes MEASURED iff that record passed -- the same rule a green JUnit testcase already
3157
+ obeys, and for the same reason: red or unbudgeted evidence closes nothing."""
3158
+ ledger = _load(args.ledger)
3159
+ node = _repo_node(ledger, args.repo)
3160
+ tool = "gate:corpus"
3161
+ _validate_iteration(node, tool, args.iteration)
3162
+ cfg = next((r for r in ledger["config"].get("repos", [])
3163
+ if r.get("name") == args.repo), {})
3164
+ defaults = ledger["config"].get("defaults", {})
3165
+
3166
+ corpus_path = args.corpus or cfg.get("corpus")
3167
+ if not corpus_path:
3168
+ _corpus_refuse("no corpus: pass --corpus PATH or declare repos[%s].corpus in the "
3169
+ "config. Where the real inputs live is the project's fact, not a "
3170
+ "default this engine may invent." % args.repo)
3171
+ cases = _corpus_cases(corpus_path)
3172
+
3173
+ threshold, source = args.threshold, "--threshold"
3174
+ if threshold is None:
3175
+ threshold, source = cfg.get("corpus_threshold"), "repos[%s].corpus_threshold" % args.repo
3176
+ if threshold is None:
3177
+ threshold, source = defaults.get("corpus_threshold"), "defaults.corpus_threshold"
3178
+ if threshold is None:
3179
+ source = None
3180
+ threshold = _corpus_num(threshold)
3181
+
3182
+ acs = []
3183
+ for raw in (args.ac or []):
3184
+ cid, _end = _ac_id_of(raw.strip())
3185
+ if cid is None:
3186
+ _corpus_refuse("--ac %r is not a criterion id ('AC-01' or 'AC-FIELD-01')" % raw)
3187
+ if cid not in acs:
3188
+ acs.append(cid)
3189
+
3190
+ hits, misses = 0, []
3191
+ for case in cases:
3192
+ ok, reason, actual = _corpus_case(args.command, case, args.timeout)
3193
+ if ok:
3194
+ hits += 1
3195
+ else:
3196
+ misses.append({"id": case["id"], "reason": reason,
3197
+ "expected": _corpus_clip(_corpus_text(case["expected"])),
3198
+ "actual": _corpus_clip(actual)})
3199
+ total = len(cases)
3200
+ pct = round(100.0 * hits / total, 1)
3201
+
3202
+ advisory = source is None
3203
+ failing = (not advisory) and pct < threshold
3204
+ if advisory:
3205
+ note = "corpus %s: %d/%d (%s %%), no threshold declared" % (
3206
+ os.path.basename(corpus_path), hits, total, pct)
3207
+ else:
3208
+ note = "corpus %s: %d/%d (%s %%) %s %s %%" % (
3209
+ os.path.basename(corpus_path), hits, total, pct,
3210
+ "<" if failing else ">=", threshold)
3211
+ rec = _append_gate_record(ledger, node, args.repo, tool, args.iteration,
3212
+ failing, len(misses), note, advisory=advisory)
3213
+ # the MEASUREMENT travels on the record, not only its verdict: readiness reads the
3214
+ # percentage back for the field line, and a reader six months later can see WHAT was
3215
+ # measured against WHICH budget instead of a bare pass.
3216
+ rec["corpus"] = {"path": corpus_path.replace("\\", "/"), "hits": hits, "total": total,
3217
+ "percent": pct, "threshold": threshold, "threshold_source": source,
3218
+ "timeout_s": args.timeout,
3219
+ "misses": misses[:max(0, args.max_misses)]}
3220
+ if acs:
3221
+ rec["ac"] = acs
3222
+ _save(args.ledger, ledger)
3223
+
3224
+ state = ("ADVISORY (measured, not gating)" if advisory
3225
+ else "FAIL" if failing else "PASS")
3226
+ out = {"repo": args.repo, "tool": tool, "verdict": state.split()[0].lower(),
3227
+ "advisory": advisory, "corpus": rec["corpus"], "ac": acs, "note": note}
3228
+ if args.json:
3229
+ print(json.dumps(out, indent=2, ensure_ascii=False))
3230
+ else:
3231
+ if advisory:
3232
+ budget = ("no threshold declared — the percentage is recorded and nothing gates "
3233
+ "(a gate needs an adopted budget, ADR-043)")
3234
+ else:
3235
+ budget = "threshold %s %% from %s" % (threshold, source)
3236
+ print("[qa_ledger] %s/%s: %s %s %% (%d/%d) — %s"
3237
+ % (args.repo, tool, state, pct, hits, total, budget))
3238
+ for m in rec["corpus"]["misses"]:
3239
+ print(" miss %s (%s): expected %r, got %r"
3240
+ % (m["id"], m["reason"], m["expected"], m["actual"]))
3241
+ if len(misses) > len(rec["corpus"]["misses"]):
3242
+ print(" ... +%d more miss(es) not listed (--max-misses)"
3243
+ % (len(misses) - len(rec["corpus"]["misses"])))
3244
+ if failing:
3245
+ print(" caps readiness <=65 and blocks convergence until a green run")
3246
+ sys.exit(1 if failing else 0)
3247
+
3248
+
3249
+ def _corpus_records(ledger):
3250
+ """The LATEST corpus record per repo -- the same latest-per-tool rule the gate rollup and
3251
+ convergence already use, so the three cannot disagree about which run is current. A record
3252
+ logged through the `log-gate --kind corpus` parity door carries no `corpus` block and is
3253
+ deliberately not here: it gates (it is a gate record like any other), but it has no run to
3254
+ read back, and rendering it anyway printed the honest-looking nonsense
3255
+ `corpus None % (None/None) >= None % PASS`. Same exclusion, same reason, as
3256
+ `_smoke_records`."""
3257
+ out = {}
3258
+ for rname, rnode in ledger.get("repos", {}).items():
3259
+ rec = _latest_static_by_tool(rnode).get("gate:corpus")
3260
+ if rec is not None and rec.get("corpus"):
3261
+ out[rname] = rec
3262
+ return out
3263
+
3264
+
3265
+ def _corpus_ac_verdicts(ledger):
3266
+ """(closed, vetoed) criterion ids from the latest corpus record of every repo (ADR-046).
3267
+
3268
+ A corpus record CLOSES an AC iff it PASSED. An **advisory** run measured a percentage
3269
+ against no adopted budget: it is not a green gate (ADR-043 refuses to let it read as one)
3270
+ and it is not red evidence either, so it neither closes nor vetoes. A **failing** run is
3271
+ evidence AGAINST, which ADR-046 said from the start -- and the engine used to merely skip
3272
+ it, so a green testcase went on closing a criterion the field had just refuted. It now
3273
+ VETOES the ids it carries, the same rule a red JUnit testcase and a failed tagged smoke
3274
+ check already obey, and for the same reason: the cheapest way to fake a closed criterion is
3275
+ to put a green beside a red."""
3276
+ closed, vetoed = set(), set()
3277
+ for rec in _corpus_records(ledger).values():
3278
+ if rec.get("advisory"):
3279
+ continue
3280
+ if (rec.get("gated_reported") or 0) > 0:
3281
+ vetoed.update(rec.get("ac") or [])
3282
+ else:
3283
+ closed.update(rec.get("ac") or [])
3284
+ return closed - vetoed, vetoed
3285
+
3286
+
3287
+ def _corpus_field(ledger):
3288
+ """Per-repo FIELD readout -- ONE derivation, read by both the readiness text and its JSON.
3289
+ CONDITIONAL like lifecycle and agent-origin: a repo that neither declares a corpus nor ever
3290
+ ran one is absent from it, so a project without field truth prints and emits exactly what
3291
+ it printed and emitted before this existed. No weight, no cap, no gate of its own -- a
3292
+ failing corpus already blocks through its `gate:corpus` record, and the `field` DIMENSION
3293
+ (its own ADR) is deliberately not here."""
3294
+ recs = _corpus_records(ledger)
3295
+ out = {}
3296
+ for rname in ledger.get("repos", {}):
3297
+ cfg = next((r for r in ledger.get("config", {}).get("repos", [])
3298
+ if r.get("name") == rname), {})
3299
+ rec, declared = recs.get(rname), cfg.get("corpus")
3300
+ if rec is None and not declared:
3301
+ continue
3302
+ if rec is None:
3303
+ out[rname] = {"state": "UNMEASURED", "corpus": declared, "percent": None,
3304
+ "hits": None, "total": None, "threshold": None,
3305
+ "reason": "a corpus is declared and was never run"}
3306
+ continue
3307
+ c = rec.get("corpus") or {}
3308
+ out[rname] = {"state": ("ADVISORY" if rec.get("advisory")
3309
+ else "FAIL" if (rec.get("gated_reported") or 0) > 0
3310
+ else "PASS"),
3311
+ "corpus": c.get("path", declared),
3312
+ "percent": c.get("percent"), "hits": c.get("hits"),
3313
+ "total": c.get("total"), "threshold": c.get("threshold"),
3314
+ "threshold_source": c.get("threshold_source"),
3315
+ "ac": rec.get("ac") or []}
3316
+ return out
3317
+
3318
+
3319
+ def _corpus_field_line(rname, f):
3320
+ """The one-line rendering of a repo's field state. Text lives beside the derivation so the
3321
+ JSON and the human readout can never drift apart."""
3322
+ if f["state"] == "UNMEASURED":
3323
+ return ("--- field %s: corpus UNMEASURED — %s (%s)"
3324
+ % (rname, f["reason"], f["corpus"]))
3325
+ head = "--- field %s: corpus %s %% (%s/%s)" % (rname, f["percent"], f["hits"], f["total"])
3326
+ if f["state"] == "ADVISORY":
3327
+ return head + " — no threshold declared, ADVISORY (measured, not gating)"
3328
+ return "%s %s %s %% %s" % (head, "<" if f["state"] == "FAIL" else ">=",
3329
+ f["threshold"], f["state"])
3330
+
3331
+
3332
+ # --------------------------------------------------------------------------- #
3333
+ # smoke-ingest (kit 2.2.0, ADR-047): the SMOKE RUN as measured evidence.
3334
+ #
3335
+ # The ledger already ingests the evidence a machine produces on its own -- JUnit, coverage,
3336
+ # linters, a static gate's XML, a CI verdict. The smoke list was the hole: "the jar served
3337
+ # /admin", "the simulator answered 200 in 6 ms" arrived as a sub-agent's NARRATION, believed
3338
+ # because it was written confidently. The field report this subcommand comes from is exactly
3339
+ # that shape: every simulator run returned an empty list because the database had no rows, and
3340
+ # a smoke narrated as "verified" would have hidden it behind a sentence.
3341
+ #
3342
+ # So the smoke run stops being prose and becomes a REPORT the project's own tool writes --
3343
+ # {"checks": [{"name", "ok", "status"?, "latency_ms"?, "evidence"?}, ...]} -- and the engine
3344
+ # ingests it like any other fact. `smoke` is a FACT kind and is never advisory: a smoke check is
3345
+ # binary. It either answered or it did not; there is no "measured against no adopted budget"
3346
+ # reading of `ok`, which is why `corpus` may run advisory and this may not.
3347
+ #
3348
+ # Evidence is EXECUTED, not narrated -- and a report the engine cannot read is refused (exit 2)
3349
+ # rather than scored, for the same reason a malformed corpus is: an unreadable smoke reported as
3350
+ # "0 checks ok" would be an unmeasurable run rendered as a measured catastrophe, and an EMPTY
3351
+ # `checks` list read as a clean gate would be the false clean in the other direction.
3352
+ # --------------------------------------------------------------------------- #
3353
+ # How many checks (and failed names) travel on the record. A receipt cites evidence, it is not
3354
+ # a dump -- the same cap `_ac_tags` puts on its testcase receipts, for the same reason.
3355
+ SMOKE_MAX_PERSISTED = 20
3356
+
3357
+
3358
+ def _smoke_refuse(msg):
3359
+ """Every smoke-ingest refusal is exit 2 and NAMES the offending check or field. The report
3360
+ is written by the PROJECT's own tool, so what this guards against is a contract the project
3361
+ got subtly wrong -- and a wrong contract that scored anyway would be worse than one that
3362
+ refused, because the number would look like a measurement."""
3363
+ print("[qa_ledger] smoke-ingest: " + msg, file=sys.stderr)
3364
+ sys.exit(2)
3365
+
3366
+
3367
+ def _smoke_checks(path):
3368
+ """Read a smoke report into an ORDERED list of checks. The contract is deliberately the
3369
+ smallest thing a shell script can emit:
3370
+
3371
+ {"checks": [{"name": "...", "ok": true|false,
3372
+ "status": <int|string, optional>,
3373
+ "latency_ms": <number, optional>,
3374
+ "evidence": "<string, optional>"}]}
3375
+
3376
+ `name` and a BOOLEAN `ok` are the whole mandatory surface. `ok: "true"` and `ok: 1` are
3377
+ refused: a string and a number are not verdicts, and a contract that coerces is a contract
3378
+ that cannot say what it measured. The optional fields are validated when PRESENT and never
3379
+ invented when absent -- a check with no `latency_ms` measured no latency, which is a
3380
+ different fact from a latency of 0."""
3381
+ if not os.path.isfile(path):
3382
+ _smoke_refuse("report not found: %s -- a smoke run that left no report is UNMEASURED, "
3383
+ "never a clean gate" % path)
3384
+ try:
3385
+ with open(path, encoding="utf-8") as fh:
3386
+ doc = json.load(fh)
3387
+ except OSError as exc:
3388
+ _smoke_refuse("report unreadable: %s" % exc)
3389
+ except ValueError as exc:
3390
+ _smoke_refuse("%s is not valid JSON (%s) -- a malformed report is refused, never "
3391
+ "scored" % (path, exc))
3392
+ if not isinstance(doc, dict):
3393
+ _smoke_refuse("%s is a %s, not a JSON object carrying a `checks` list"
3394
+ % (path, type(doc).__name__))
3395
+ if "checks" not in doc:
3396
+ _smoke_refuse('%s has no `checks` key -- the contract is {"checks": [{"name": ..., '
3397
+ '"ok": true|false}, ...]}' % path)
3398
+ raw = doc["checks"]
3399
+ if not isinstance(raw, list):
3400
+ _smoke_refuse("%s: `checks` is a %s, not a list" % (path, type(raw).__name__))
3401
+ if not raw:
3402
+ _smoke_refuse("%s holds 0 checks -- an EMPTY smoke is not evidence: it is a run that "
3403
+ "verified nothing, and it does not read as a clean gate" % path)
3404
+ checks = []
3405
+ for i, row in enumerate(raw):
3406
+ where = "check %d" % (i + 1)
3407
+ if not isinstance(row, dict):
3408
+ _smoke_refuse("%s: %s is a %s, not an object" % (path, where, type(row).__name__))
3409
+ name = row.get("name")
3410
+ if not isinstance(name, str) or not name.strip():
3411
+ _smoke_refuse("%s: %s has no `name` -- a check nobody can name is a check nobody "
3412
+ "can act on" % (path, where))
3413
+ where = "%s (%s)" % (where, name.strip())
3414
+ if not isinstance(row.get("ok"), bool):
3415
+ _smoke_refuse("%s: %s has no boolean `ok` (got %r) -- a smoke check is a binary "
3416
+ "fact, and a string, a number or a missing key is not a verdict"
3417
+ % (path, where, row.get("ok")))
3418
+ status = row.get("status")
3419
+ if status is not None and (isinstance(status, bool)
3420
+ or not isinstance(status, (int, str))):
3421
+ _smoke_refuse("%s: %s has a `status` that is neither an integer nor a string (%r)"
3422
+ % (path, where, status))
3423
+ lat = row.get("latency_ms")
3424
+ if lat is not None and (isinstance(lat, bool) or not isinstance(lat, (int, float))):
3425
+ _smoke_refuse("%s: %s has a `latency_ms` that is not a number (%r)"
3426
+ % (path, where, lat))
3427
+ ev = row.get("evidence")
3428
+ if ev is not None and not isinstance(ev, str):
3429
+ _smoke_refuse("%s: %s has an `evidence` that is not a string (%r)"
3430
+ % (path, where, ev))
3431
+ checks.append({"name": name.strip(), "ok": row["ok"], "status": status,
3432
+ "latency_ms": lat, "evidence": ev})
3433
+ return checks
3434
+
3435
+
3436
+ def _smoke_ac_of(checks, want_ok):
3437
+ """Criterion ids tagged on check NAMES, in the SAME grammar a JUnit testcase name uses
3438
+ (`_ac_tag_ids`, ADR-036): a check named "AC-28 the admin page serves" tags AC-28. One
3439
+ extractor, so a name cannot close a criterion in the suite and fail to close it here."""
3440
+ ids = []
3441
+ for c in checks:
3442
+ if bool(c["ok"]) != want_ok:
3443
+ continue
3444
+ for cid in _ac_tag_ids(c["name"]):
3445
+ if cid not in ids:
3446
+ ids.append(cid)
3447
+ return sorted(ids, key=_top_ac_key)
3448
+
3449
+
3450
+ def cmd_smoke_ingest(args):
3451
+ """Ingest a smoke report as `gate:smoke` -- the smoke run MEASURED instead of narrated.
3452
+
3453
+ A failed check is a BLOCKER through the SAME record shape `gate-check` and `corpus-run`
3454
+ write: readiness capped <=65, convergence blocked, and a later clean report clears it
3455
+ (latest-per-tool wins). `smoke` is a FACT kind and is NOT advisory-capable: `ok` is binary,
3456
+ so there is no budget it could be measured against and no honest advisory reading of it.
3457
+
3458
+ A check whose NAME carries an AC tag closes that criterion MEASURED iff the check is `ok`
3459
+ AND the report as a whole passed -- and a FAILED tagged check is red evidence that vetoes,
3460
+ exactly like a red JUnit testcase. Fail-closed in both directions: a green check inside a
3461
+ failing smoke closes nothing, because the run it belongs to did not hold."""
3462
+ ledger = _load(args.ledger)
3463
+ node = _repo_node(ledger, args.repo)
3464
+ tool = "gate:smoke"
3465
+ _validate_iteration(node, tool, args.iteration)
3466
+ checks = _smoke_checks(args.report)
3467
+
3468
+ n_ok = sum(1 for c in checks if c["ok"])
3469
+ failed = [c for c in checks if not c["ok"]]
3470
+ failing = bool(failed)
3471
+ failed_names = [c["name"] for c in failed]
3472
+ note = "smoke %s: %d/%d checks ok%s" % (
3473
+ os.path.basename(args.report), n_ok, len(checks),
3474
+ (", %d failed (%s)" % (len(failed), ", ".join(failed_names[:SMOKE_MAX_PERSISTED])))
3475
+ if failed else "")
3476
+ rec = _append_gate_record(ledger, node, args.repo, tool, args.iteration,
3477
+ failing, len(failed), note)
3478
+ # the MEASUREMENT travels on the record, not only its verdict: a reader six months later
3479
+ # sees WHICH checks ran, what each answered and how long it took -- the facts the narration
3480
+ # used to carry and lose.
3481
+ rec["smoke"] = {
3482
+ "report": args.report.replace("\\", "/"), "ok": n_ok, "failed": len(failed),
3483
+ "checks": [{"name": c["name"], "ok": c["ok"], "status": c["status"],
3484
+ "latency_ms": c["latency_ms"]} for c in checks[:SMOKE_MAX_PERSISTED]],
3485
+ "failed_names": failed_names[:SMOKE_MAX_PERSISTED],
3486
+ # computed over EVERY check, never over the truncated receipt above: the cap is a
3487
+ # display budget, and a criterion's fate must not depend on where the list was cut.
3488
+ "ac": _smoke_ac_of(checks, True),
3489
+ "ac_red": _smoke_ac_of(checks, False),
3490
+ }
3491
+ _save(args.ledger, ledger)
3492
+
3493
+ out = {"repo": args.repo, "tool": tool, "verdict": "fail" if failing else "pass",
3494
+ "smoke": rec["smoke"], "note": note}
3495
+ if args.json:
3496
+ print(json.dumps(out, indent=2, ensure_ascii=False))
3497
+ else:
3498
+ print("[qa_ledger] %s/%s: %s — %d/%d checks ok"
3499
+ % (args.repo, tool, "FAIL" if failing else "PASS", n_ok, len(checks)))
3500
+ for c in checks[:SMOKE_MAX_PERSISTED]:
3501
+ bits = [b for b in ("status %s" % c["status"] if c["status"] is not None else None,
3502
+ "%s ms" % c["latency_ms"] if c["latency_ms"] is not None
3503
+ else None) if b]
3504
+ print(" %s %s%s" % ("ok " if c["ok"] else "FAIL", c["name"],
3505
+ (" (%s)" % ", ".join(bits)) if bits else ""))
3506
+ if len(checks) > SMOKE_MAX_PERSISTED:
3507
+ print(" ... +%d more check(s) not listed or persisted"
3508
+ % (len(checks) - SMOKE_MAX_PERSISTED))
3509
+ if failing:
3510
+ print(" caps readiness <=65 and blocks convergence until a clean smoke")
3511
+ sys.exit(1 if failing else 0)
3512
+
3513
+
3514
+ def _smoke_records(ledger):
3515
+ """The LATEST smoke record per repo -- the same latest-per-tool rule the gate rollup and
3516
+ convergence already use, so the three cannot disagree about which run is current. A record
3517
+ logged through the `log-gate` parity door carries no `smoke` block and is deliberately not
3518
+ here: it gates (it is a gate record like any other), but it has no checks to read back."""
3519
+ out = {}
3520
+ for rname, rnode in ledger.get("repos", {}).items():
3521
+ rec = _latest_static_by_tool(rnode).get("gate:smoke")
3522
+ if rec is not None and rec.get("smoke"):
3523
+ out[rname] = rec
3524
+ return out
3525
+
3526
+
3527
+ def _smoke_ac_verdicts(ledger):
3528
+ """(closed, vetoed) criterion ids from the latest smoke record of every repo (ADR-047).
3529
+
3530
+ A tagged check closes MEASURED iff it is `ok` AND its report's gate PASSED: a green check
3531
+ inside a failing smoke is a green light on a run that did not hold, and the ledger refuses
3532
+ to read it as one. A FAILED tagged check is red evidence and vetoes wherever it appears --
3533
+ the same rule as a red JUnit testcase, and it outranks every green."""
3534
+ closed, vetoed = set(), set()
3535
+ for rec in _smoke_records(ledger).values():
3536
+ s = rec.get("smoke") or {}
3537
+ vetoed.update(s.get("ac_red") or [])
3538
+ if not (rec.get("gated_reported") or 0):
3539
+ closed.update(s.get("ac") or [])
3540
+ return closed - vetoed, vetoed
3541
+
3542
+
3543
+ def _smoke_report(ledger):
3544
+ """Per-repo SMOKE readout -- ONE derivation, read by both the readiness text and its JSON.
3545
+ CONDITIONAL like lifecycle, agent-origin and field: a repo that never ingested a smoke
3546
+ report is absent from it, so a project that never ran one prints and emits exactly what it
3547
+ printed and emitted before this existed."""
3548
+ out = {}
3549
+ for rname, rec in _smoke_records(ledger).items():
3550
+ s = rec["smoke"]
3551
+ out[rname] = {"state": "FAIL" if (rec.get("gated_reported") or 0) else "PASS",
3552
+ "report": s.get("report"), "ok": s.get("ok"),
3553
+ "failed": s.get("failed"),
3554
+ "total": (s.get("ok") or 0) + (s.get("failed") or 0),
3555
+ "failed_names": s.get("failed_names") or [],
3556
+ "ac": s.get("ac") or [], "ac_red": s.get("ac_red") or []}
3557
+ return out
3558
+
3559
+
3560
+ def _smoke_line(rname, s):
3561
+ """The one-line rendering of a repo's smoke state. Text lives beside the derivation so the
3562
+ JSON and the human readout can never drift apart."""
3563
+ head = "--- smoke %s: %d/%d checks ok" % (rname, s["ok"], s["total"])
3564
+ if s["failed"]:
3565
+ head += ", %d failed (%s)" % (s["failed"], ", ".join(s["failed_names"]))
3566
+ return "%s %s" % (head, s["state"])
2830
3567
 
2831
3568
 
2832
3569
  def cmd_flag_blocker(args):
@@ -3013,6 +3750,16 @@ def _derive_phase(ledger, name, node, k, qa_order):
3013
3750
  if len(_dl["uncurated"]) > 3 else "")
3014
3751
  + " -- INV-CURATION-01: sin juicio no hay promocion")
3015
3752
  conv = False
3753
+ # operability gate (ADR-048). NAMING only: a failing record is already a BLOCKER through
3754
+ # _gate_open_and_sev and already vetoes convergence through _converged, so nothing new is
3755
+ # gated here. What is added is WHICH check is missing -- "static-gate gated=1
3756
+ # (gate:operability:1)" tells a human the gate is red without telling them whether to write
3757
+ # a workflow or a RUNBOOK, and phase --require pr-ready is where that answer is needed.
3758
+ _op = _latest_static_by_tool(node).get("gate:operability")
3759
+ if _op and (_op.get("gated_reported") or 0) > 0:
3760
+ reasons.append("operability: %s -- release, reset and the RUNBOOK are part of done, "
3761
+ "not of the last week (ADR-048)"
3762
+ % (_op.get("note") or "checks missing"))
3016
3763
  _cr = _cr_cfg(ledger)
3017
3764
  if _cr and _cr.get("mode") == "final":
3018
3765
  _head = None
@@ -3502,15 +4249,35 @@ def cmd_spec_drift(args):
3502
4249
  lag_days = int(args.max_lag_days if args.max_lag_days is not None
3503
4250
  else cfg.get("max_lag_days", 30))
3504
4251
  repo_path = _scope_path(ledger, args.repo)
4252
+ root_path = os.path.dirname(os.path.abspath(args.ledger)) or "."
3505
4253
 
3506
4254
  # The spec surface is fixed by ADR-005: the repo SPEC.md plus every ADR.
3507
- spec_files = []
3508
- if os.path.isfile(os.path.join(repo_path, "SPEC.md")):
3509
- spec_files.append("SPEC.md")
3510
- adr_dir = os.path.join(repo_path, "docs", "adr")
3511
- if os.path.isdir(adr_dir):
3512
- spec_files += sorted("docs/adr/" + f for f in os.listdir(adr_dir)
3513
- if f.lower().endswith(".md"))
4255
+ def _specs_at(base):
4256
+ found = []
4257
+ if os.path.isfile(os.path.join(base, "SPEC.md")):
4258
+ found.append("SPEC.md")
4259
+ adr_dir = os.path.join(base, "docs", "adr")
4260
+ if os.path.isdir(adr_dir):
4261
+ found += sorted("docs/adr/" + f for f in os.listdir(adr_dir)
4262
+ if f.lower().endswith(".md"))
4263
+ return found
4264
+
4265
+ # 2.2.0 field fix: a MONOREPO keeps ONE SPEC.md and one docs/adr/ at the root, next to
4266
+ # uscha.config.json, while repos[R].path points at a subdirectory -- and this command read
4267
+ # only that subdirectory, so `spec-drift --repo backend-api` answered "no spec documents"
4268
+ # about a project whose spec was one level up. A spec found at the root is the MONOREPO's
4269
+ # spec and governs every repo. The repo's own path still WINS when it has one (a repo that
4270
+ # carries its own SPEC is describing itself); the config root -- taken as the ledger's
4271
+ # directory, which is where `init --config uscha.config.json` is run -- is the fallback.
4272
+ # The answer NAMES which of the two it read: "no drift" and "read the wrong tree" produced
4273
+ # the same silence, and that is what made the field report take a week to notice.
4274
+ base_path, spec_source = repo_path, "repo"
4275
+ spec_files = _specs_at(repo_path)
4276
+ if not spec_files and os.path.realpath(root_path) != os.path.realpath(repo_path):
4277
+ root_specs = _specs_at(root_path)
4278
+ if root_specs:
4279
+ base_path, spec_source, spec_files = root_path, "root", root_specs
4280
+ repo_path = base_path
3514
4281
 
3515
4282
  tracked = []
3516
4283
  ls = subprocess.run(["git", "ls-files"], cwd=repo_path, capture_output=True,
@@ -3582,20 +4349,26 @@ def cmd_spec_drift(args):
3582
4349
  results.append(row)
3583
4350
 
3584
4351
  out = {"repo": args.repo, "max_lag_days": lag_days, "results": results,
3585
- "advisory": True}
4352
+ "advisory": True, "spec_source": spec_source if spec_files else None,
4353
+ "spec_base": repo_path}
3586
4354
 
3587
4355
  # Latest-state record so the mirador can surface an advisory row. Advisory data,
3588
4356
  # not a step in the loop: no step_counter, no gate record, no readiness input.
3589
4357
  ledger["spec_drift"] = {"repo": args.repo, "at": _now(), "max_lag_days": lag_days,
3590
- "results": results}
4358
+ "results": results,
4359
+ "spec_source": spec_source if spec_files else None}
3591
4360
  _save(args.ledger, ledger)
3592
4361
 
3593
4362
  if args.json:
3594
4363
  print(json.dumps(out, indent=2, ensure_ascii=False))
3595
4364
  else:
3596
4365
  print("SPEC-DRIFT %s (advisory, lag > %dd):" % (args.repo, lag_days))
4366
+ if spec_source == "root" and spec_files:
4367
+ print(" specs read from the CONFIG ROOT (%s): the monorepo's SPEC governs "
4368
+ "every repo" % repo_path)
3597
4369
  if not results:
3598
- print(" no spec documents found (SPEC.md / docs/adr/*.md)")
4370
+ print(" no spec documents found (SPEC.md / docs/adr/*.md) in the repo path "
4371
+ "nor at the config root")
3599
4372
  mark = {"SPEC_STALE": "!!", "CLEAN": "ok", "UNMAPPED": "--", "UNTRACKED": "--",
3600
4373
  "NO-CODE": "ok"}
3601
4374
  for r_ in results:
@@ -3610,6 +4383,330 @@ def cmd_spec_drift(args):
3610
4383
  sys.exit(0)
3611
4384
 
3612
4385
 
4386
+ # --------------------------------------------------------------------------- #
4387
+ # operability (ADR-048: release, reset and the RUNBOOK are MEASURED, not narrated)
4388
+ # --------------------------------------------------------------------------- #
4389
+ # The field finding, twice in a row: release-by-CI, the reset/seed script and the RUNBOOK
4390
+ # arrived in the last week of two projects. The devloop NAMED them in phase 8 prose and
4391
+ # nothing measured them, so "we'll do it at the end" survived every gate the kit has -- the
4392
+ # same shape as every other narrated dimension this engine has replaced with a fact.
4393
+ #
4394
+ # What this command reads is FILES IN THE TREE, never prose and never a claim: a workflow that
4395
+ # runs the repo's own test command, a workflow that publishes something, a RUNBOOK with the
4396
+ # four headings an operator needs at 3am, and a seed/reset command the config declares whose
4397
+ # script is actually on disk. It cannot read whether the RUNBOOK is GOOD -- that is a human
4398
+ # judgment and it is not pretended here. It can read that the file exists and that the
4399
+ # sections are named, which is the difference between a project that thought about rollback
4400
+ # and one that has not yet.
4401
+ #
4402
+ # It never executes anything (ADR-008: the engine is not an executor of config-supplied
4403
+ # shell) and its own exit code is always 0. Whether the verdict GATES is the project's
4404
+ # declaration -- `defaults.operability.gate`, owned by risk profiles C, D and E.
4405
+ OPERABILITY_CHECKS = ("ci", "release", "runbook", "seed")
4406
+
4407
+ # GitHub Actions is the only CI this reads. Another system is NAMED as unknown rather than
4408
+ # failed: the engine cannot open a pipeline it does not understand, and a red invented for a
4409
+ # pipeline nobody read would be exactly the manufactured verdict the kit refuses elsewhere.
4410
+ OPERABILITY_CI_DIR = (".github", "workflows")
4411
+ OPERABILITY_OTHER_CI = (
4412
+ ".gitlab-ci.yml", ".travis.yml", "azure-pipelines.yml", "bitbucket-pipelines.yml",
4413
+ "Jenkinsfile", ".circleci", ".drone.yml", ".teamcity",
4414
+ )
4415
+ # The publish recognisers, listed rather than guessed: a workflow step that creates or
4416
+ # attaches a release asset. Short and documented on purpose -- a regex over "release" would
4417
+ # match a branch name, a job title and a comment, and a gate that matches prose is a gate that
4418
+ # certifies prose.
4419
+ OPERABILITY_RELEASE_MARKERS = (
4420
+ "softprops/action-gh-release",
4421
+ "actions/upload-release-asset",
4422
+ "gh release create",
4423
+ "gh release upload",
4424
+ "gh release",
4425
+ "npm publish",
4426
+ "twine upload",
4427
+ )
4428
+ # A test-ish subcommand, for the case where the workflow does not repeat the configured
4429
+ # command verbatim (a Makefile target, an extra flag, a matrix variable in the middle).
4430
+ OPERABILITY_TEST_WORDS = ("test", "tests", "pytest", "nextest", "jest", "check", "verify")
4431
+ # The four headings an operator needs, matched case-insensitively in EN and ES. What is
4432
+ # matched is the HEADING, not the body: the engine can see that rollback was thought about,
4433
+ # never that the procedure is correct.
4434
+ OPERABILITY_RUNBOOK_SECTIONS = (
4435
+ ("start", r"arranque|start|boot"),
4436
+ ("config", r"config"),
4437
+ ("rollback", r"rollback|reversi"),
4438
+ ("smoke", r"smoke|humo"),
4439
+ )
4440
+ OPERABILITY_RUNBOOK_PATHS = ("docs/RUNBOOK.md", "RUNBOOK.md")
4441
+ OPERABILITY_SCRIPT_EXT = (".sh", ".py", ".ps1", ".bat", ".sql", ".js", ".ts", ".rb")
4442
+
4443
+
4444
+ def _op_bases(ledger, repo, ledger_path):
4445
+ """(repo path, config root) with realpath on BOTH sides -- the Windows 8.3 lesson, and the
4446
+ monorepo lesson spec-drift paid for in 2.2.0: the workflows and the RUNBOOK of a monorepo
4447
+ live at the config root while repos[R].path points at a subdirectory. The repo's own tree
4448
+ WINS when it has the artifact; the root is the fallback, and the answer NAMES which one it
4449
+ read, because "no CI" and "read the wrong tree" produced the same silence."""
4450
+ repo_path = _scope_path(ledger, repo)
4451
+ root_path = os.path.dirname(os.path.abspath(ledger_path)) or "."
4452
+ bases = [(repo_path, "repo")]
4453
+ if os.path.realpath(root_path) != os.path.realpath(repo_path):
4454
+ bases.append((root_path, "root"))
4455
+ return bases
4456
+
4457
+
4458
+ def _op_workflows(bases):
4459
+ """(list of (relative name, text), base, where) for the first base that HAS a GitHub
4460
+ Actions directory, else ([], None, None)."""
4461
+ for base, where in bases:
4462
+ wdir = os.path.join(base, *OPERABILITY_CI_DIR)
4463
+ if not os.path.isdir(wdir):
4464
+ continue
4465
+ files = []
4466
+ for name in sorted(os.listdir(wdir)):
4467
+ if not name.lower().endswith((".yml", ".yaml")):
4468
+ continue
4469
+ try:
4470
+ with open(os.path.join(wdir, name), encoding="utf-8",
4471
+ errors="replace") as fh:
4472
+ files.append((name, fh.read()))
4473
+ except OSError:
4474
+ continue
4475
+ if files:
4476
+ return files, base, where
4477
+ return [], None, None
4478
+
4479
+
4480
+ def _op_other_ci(bases):
4481
+ """The first non-Actions CI marker found, as `name (where)`, or None."""
4482
+ for base, where in bases:
4483
+ for name in OPERABILITY_OTHER_CI:
4484
+ if os.path.exists(os.path.join(base, name)):
4485
+ return "%s (%s)" % (name, where)
4486
+ return None
4487
+
4488
+
4489
+ def _op_test_command(ledger, repo):
4490
+ """The repo's CONFIGURED test command: repos[R].test_command, else the per-type
4491
+ defaults.test_command_<type>. Read, never run."""
4492
+ cfg = ledger.get("config") or {}
4493
+ defaults = cfg.get("defaults") or {}
4494
+ entry = {}
4495
+ for r in cfg.get("repos", []):
4496
+ if r.get("name") == repo:
4497
+ entry = r
4498
+ break
4499
+ if _has_text(entry.get("test_command")):
4500
+ return entry["test_command"].strip()
4501
+ rtype = entry.get("type") or (ledger.get("repos", {}).get(repo) or {}).get("type")
4502
+ value = defaults.get("test_command_%s" % rtype) if rtype else None
4503
+ return value.strip() if _has_text(value) else None
4504
+
4505
+
4506
+ def _op_ci(workflows, command):
4507
+ """(status, detail). `ok` when a workflow step runs the configured command -- verbatim, or
4508
+ its first token beside a test-ish subcommand on the same line. The detail SAYS what
4509
+ matched, so the human can disagree with the match instead of with a boolean."""
4510
+ if not command:
4511
+ return "missing", "no test command configured for this repo (nothing to look for)"
4512
+ head = command.split()[0]
4513
+ tail = os.path.basename(head)
4514
+ for name, body in workflows:
4515
+ for line in body.splitlines():
4516
+ stripped = line.strip()
4517
+ if command in line:
4518
+ return "ok", '%s runs "%s"' % (name, command)
4519
+ if head not in line and tail not in line:
4520
+ continue
4521
+ words = re.split(r"[^A-Za-z0-9_.-]+", stripped.lower())
4522
+ if any(w in OPERABILITY_TEST_WORDS for w in words):
4523
+ shown = re.sub(r"^-\s*", "", stripped)
4524
+ shown = re.sub(r"^run:\s*", "", shown)
4525
+ return "ok", '%s runs "%s"' % (name, shown[:70])
4526
+ return "missing", ("no workflow step runs the configured test command (%s)"
4527
+ % command)
4528
+
4529
+
4530
+ def _op_release(workflows):
4531
+ for name, body in workflows:
4532
+ for marker in OPERABILITY_RELEASE_MARKERS:
4533
+ if marker in body:
4534
+ return "ok", '%s runs "%s"' % (name, marker)
4535
+ return "missing", ("no workflow publishes or attaches a release asset (looked for: %s)"
4536
+ % ", ".join(OPERABILITY_RELEASE_MARKERS))
4537
+
4538
+
4539
+ def _op_runbook(bases, declared):
4540
+ """(status, detail, path). `declared` is defaults.operability.runbook when the project set
4541
+ one; otherwise docs/RUNBOOK.md then RUNBOOK.md, repo tree before config root."""
4542
+ candidates = [declared] if _has_text(declared) else list(OPERABILITY_RUNBOOK_PATHS)
4543
+ for base, where in bases:
4544
+ for rel in candidates:
4545
+ path = os.path.join(base, rel.replace("/", os.sep))
4546
+ if not os.path.isfile(path):
4547
+ continue
4548
+ try:
4549
+ with open(path, encoding="utf-8", errors="replace") as fh:
4550
+ body = fh.read()
4551
+ except OSError as exc:
4552
+ return "missing", "%s unreadable: %s" % (rel, exc), rel
4553
+ heads = [row.lstrip("#").strip().lower()
4554
+ for row in body.splitlines() if row.lstrip().startswith("#")]
4555
+ blob = "\n".join(heads)
4556
+ absent = [label for label, pattern in OPERABILITY_RUNBOOK_SECTIONS
4557
+ if not re.search(pattern, blob, re.I)]
4558
+ if absent:
4559
+ return ("missing", "missing sections (%s) in %s [%s]"
4560
+ % (", ".join(absent), rel, where), rel)
4561
+ return "ok", "%s [%s], all four sections named" % (rel, where), rel
4562
+ return ("missing", "no RUNBOOK found (looked for %s)" % ", ".join(candidates), None)
4563
+
4564
+
4565
+ def _op_seed(ledger, repo, bases, defaults_op):
4566
+ """(status, detail). The seed/reset command is DECLARED, never sniffed: an engine that
4567
+ guessed which script resets the database would be guessing about the most destructive
4568
+ command in the project."""
4569
+ entry = {}
4570
+ for r in (ledger.get("config") or {}).get("repos", []):
4571
+ if r.get("name") == repo:
4572
+ entry = r
4573
+ break
4574
+ repo_op = entry.get("operability") if isinstance(entry.get("operability"), dict) else {}
4575
+ command = repo_op.get("seed_command")
4576
+ if not _has_text(command):
4577
+ command = defaults_op.get("seed_command")
4578
+ if not _has_text(command):
4579
+ return "missing", ("no seed/reset command declared "
4580
+ "(repos[R].operability.seed_command or "
4581
+ "defaults.operability.seed_command)")
4582
+ command = command.strip()
4583
+ script = None
4584
+ for token in re.split(r"\s+", command):
4585
+ bare = token.strip("\"'")
4586
+ if "/" in bare or "\\" in bare or bare.lower().endswith(OPERABILITY_SCRIPT_EXT):
4587
+ script = bare
4588
+ break
4589
+ if script is None:
4590
+ # A command with no path in it (`make seed`, `npm run reset`) names a target this
4591
+ # engine cannot resolve without running something. Declared is what is measurable.
4592
+ return "ok", 'declared: "%s" (no script path to verify)' % command
4593
+ for base, _where in bases:
4594
+ if os.path.exists(os.path.join(base, script.replace("/", os.sep))):
4595
+ return "ok", 'declared: "%s"' % command
4596
+ return "missing", "script not found: %s" % script
4597
+
4598
+
4599
+ def _operability_report(ledger, repo, ledger_path):
4600
+ """The four FACTS, with the base each was read from. Pure measurement: no persistence,
4601
+ no exit code, no gate decision -- the caller owns all three."""
4602
+ defaults = (ledger.get("config") or {}).get("defaults") or {}
4603
+ op_cfg = defaults.get("operability") if isinstance(defaults.get("operability"), dict) else {}
4604
+ bases = _op_bases(ledger, repo, ledger_path)
4605
+ workflows, wf_base, wf_where = _op_workflows(bases)
4606
+ checks = {}
4607
+ if workflows:
4608
+ ci_status, ci_detail = _op_ci(workflows, _op_test_command(ledger, repo))
4609
+ rel_status, rel_detail = _op_release(workflows)
4610
+ ci_source = "%s [%s]" % ("/".join(OPERABILITY_CI_DIR), wf_where)
4611
+ else:
4612
+ other = _op_other_ci(bases)
4613
+ ci_source = None
4614
+ if other:
4615
+ ci_status = rel_status = "unknown"
4616
+ ci_detail = rel_detail = ("unknown ci system: %s -- this reads GitHub Actions "
4617
+ "only, so it neither certifies nor condemns it" % other)
4618
+ else:
4619
+ ci_status = rel_status = "missing"
4620
+ ci_detail = rel_detail = ("no %s in the repo tree nor at the config root"
4621
+ % "/".join(OPERABILITY_CI_DIR))
4622
+ checks["ci"] = {"status": ci_status, "detail": ci_detail}
4623
+ checks["release"] = {"status": rel_status, "detail": rel_detail}
4624
+ rb_status, rb_detail, rb_path = _op_runbook(bases, op_cfg.get("runbook"))
4625
+ checks["runbook"] = {"status": rb_status, "detail": rb_detail, "path": rb_path}
4626
+ sd_status, sd_detail = _op_seed(ledger, repo, bases, op_cfg)
4627
+ checks["seed"] = {"status": sd_status, "detail": sd_detail}
4628
+ return {"repo": repo, "checks": checks, "ci_source": ci_source,
4629
+ "bases": [{"path": b, "where": w} for b, w in bases]}
4630
+
4631
+
4632
+ def _operability_verdict(report, gate):
4633
+ """(verdict, summary note). Three states, because there are three facts:
4634
+ - every check `ok` -> pass (a gate that ran clean)
4635
+ - any check `missing` -> fail under a declared gate, advisory without
4636
+ - none missing, some `unknown` -> advisory even under a declared gate
4637
+ The third is the one worth spelling out: a pipeline this engine cannot read is UNMEASURED,
4638
+ and recording UNMEASURED as `pass` would hand a declared gate a green nobody measured --
4639
+ the false clean ADR-043 exists to refuse, arriving through a different door."""
4640
+ statuses = [report["checks"][k]["status"] for k in OPERABILITY_CHECKS]
4641
+ note = " · ".join("%s %s" % (k, report["checks"][k]["status"])
4642
+ for k in OPERABILITY_CHECKS)
4643
+ if not gate:
4644
+ return "advisory", note
4645
+ if "missing" in statuses:
4646
+ return "fail", note
4647
+ if "unknown" in statuses:
4648
+ return "advisory", note
4649
+ return "pass", note
4650
+
4651
+
4652
+ def cmd_operability(args):
4653
+ """Measure the operability of a repo: CI, release, RUNBOOK, seed (ADR-048).
4654
+
4655
+ Exit code is ALWAYS 0 for the check itself -- the gate decision belongs to the profile,
4656
+ and it is enforced where every other FACT gate is enforced: the persisted record caps
4657
+ readiness <=65 and blocks convergence when it is a `fail`."""
4658
+ ledger = _load(args.ledger)
4659
+ node = _repo_node(ledger, args.repo)
4660
+ resolved, origin = _resolved_defaults(ledger.get("config") or {})
4661
+ gate = bool(resolved.get("operability.gate"))
4662
+ report = _operability_report(ledger, args.repo, args.ledger)
4663
+ verdict, note = _operability_verdict(report, gate)
4664
+ missing = [k for k in OPERABILITY_CHECKS
4665
+ if report["checks"][k]["status"] == "missing"]
4666
+ report.update({"gate": gate, "gate_origin": origin.get("operability.gate"),
4667
+ "verdict": verdict, "note": note, "missing": missing})
4668
+
4669
+ rec = _append_gate_record(ledger, node, args.repo, "gate:operability", args.iteration,
4670
+ verdict == "fail", len(missing) or 1, note,
4671
+ advisory=(verdict == "advisory"))
4672
+ _save(args.ledger, ledger)
4673
+ report["step"] = rec["n"]
4674
+
4675
+ if args.json:
4676
+ print(json.dumps(report, indent=2, ensure_ascii=False))
4677
+ sys.exit(0)
4678
+ # "not declared" and "declared false" are DIFFERENT facts: the first is a project that
4679
+ # never mentioned the knob, the second a human who turned the gate off on purpose. Saying
4680
+ # "not declared" for both erased the decision (fresh review, 2.2.0).
4681
+ gate_origin = origin.get("operability.gate")
4682
+ gate_shown = ("declared" if gate
4683
+ else "declared false" if gate_origin == "override" else "not declared")
4684
+ print("OPERABILITY %s (gate: %s, origin %s)" % (args.repo, gate_shown, gate_origin))
4685
+ if report["ci_source"]:
4686
+ print(" read: %s" % report["ci_source"])
4687
+ for key in OPERABILITY_CHECKS:
4688
+ c = report["checks"][key]
4689
+ mark = {"ok": "ok", "missing": "!!", "unknown": "--"}.get(c["status"], "??")
4690
+ detail = c["detail"]
4691
+ print(" %s %s: %s%s" % (mark, key, c["status"],
4692
+ (" (%s)" % detail) if detail else ""))
4693
+ present = sum(1 for k in OPERABILITY_CHECKS
4694
+ if report["checks"][k]["status"] == "ok")
4695
+ if verdict == "fail":
4696
+ print(" -- %d of %d present -- FAIL: caps readiness <=65 and blocks convergence "
4697
+ "until %s exist(s)" % (present, len(OPERABILITY_CHECKS), ", ".join(missing)))
4698
+ elif verdict == "pass":
4699
+ print(" -- %d of %d present -- PASS: the declared operability gate ran clean"
4700
+ % (present, len(OPERABILITY_CHECKS)))
4701
+ else:
4702
+ print(" -- %d of %d present -- ADVISORY (measured, not gating): caps nothing, "
4703
+ "blocks nothing%s" % (present, len(OPERABILITY_CHECKS),
4704
+ ("; declare defaults.operability.gate: true (or a risk "
4705
+ "profile C/D/E) to make it a gate" if not gate else
4706
+ "; a check this engine cannot read is UNMEASURED, "
4707
+ "never a green")))
4708
+ sys.exit(0)
4709
+
3613
4710
 
3614
4711
  # --------------------------------------------------------------------------- #
3615
4712
  # golden-coverage (ADR-006: the golden<->source mapping, DERIVED BY MEASUREMENT)
@@ -7366,27 +8463,147 @@ def _render_lang_md(r, has_js=False):
7366
8463
  # --------------------------------------------------------------------------- #
7367
8464
 
7368
8465
  FACTS_FILE = "SYSTEM-FACTS.json"
8466
+ # The NAMESPACED name `install-uscha.py` writes the kit's version under, beside the installed
8467
+ # skills (2.2.0). It is not `VERSION`: that bare name is shared with whatever else the agent's
8468
+ # skills directory holds, and an installer that writes it owns a file it did not create.
8469
+ KIT_VERSION_COPY = ".uscha-kit-VERSION"
8470
+
8471
+
8472
+ def _kit_root():
8473
+ """The kit directory this engine belongs to, or None.
8474
+
8475
+ By MARKER, not by fixed depth: the canonical engine sits 4 levels deep
8476
+ (.claude/skills/uscha-devloop/) and the Codex twin 3 (skills/uscha-devloop/). A fixed
8477
+ dirname walk made the twin silently derive the OUTER repo root -- version None,
8478
+ 0 skills, no error (fresh-review HIGH, reproduced by running both copies).
8479
+
8480
+ The marker is a VERSION file AND a skills tree beside it (2.2.0). VERSION alone stopped
8481
+ being sufficient the moment `install-uscha.py` began dropping one beside the installed
8482
+ skills so `doctor` could date them: that root carries a version and no kit, and answering
8483
+ it here would make `facts` derive `0 skills` from a directory that never held any --
8484
+ a manufactured fact, which is worse than the honest refusal this returns instead.
8485
+ An engine that only needs the VERSION asks _engine_kit_version().
8486
+
8487
+ The walk starts at the engine's REALPATH: a `--mode link` install puts a link to the kit's
8488
+ skill directory under the agent's skills root, and an abspath walk-up from there climbs the
8489
+ agent's tree instead of the checkout the link points into (fresh review, 2.2.0). Same lesson
8490
+ the Windows 8.3 gotcha teaches: resolve before you compare, resolve before you walk."""
8491
+ cur = os.path.dirname(os.path.realpath(__file__))
8492
+ for _ in range(6):
8493
+ if (os.path.isfile(os.path.join(cur, "VERSION"))
8494
+ and (os.path.isdir(os.path.join(cur, ".claude", "skills"))
8495
+ or os.path.isdir(os.path.join(cur, "skills")))):
8496
+ return cur
8497
+ nxt = os.path.dirname(cur)
8498
+ if nxt == cur:
8499
+ break
8500
+ cur = nxt
8501
+ return None
7369
8502
 
7370
8503
 
7371
- def _derive_facts():
7372
- """Facts derived from the ARTIFACTS themselves, never from prose and never from greps
7373
- over documentation: the subcommand list comes from introspecting the REAL parser, the
7374
- skill list from the REAL kit tree, the version from the kit VERSION file. No timestamp
7375
- on purpose: regeneration over an unchanged repo must be byte-identical (AC-SF-01)."""
7376
- here = os.path.abspath(__file__)
7377
- # kit root by MARKER, not by fixed depth: the canonical engine sits 4 levels deep
7378
- # (.claude/skills/uscha-devloop/) and the Codex twin 3 (skills/uscha-devloop/). A fixed
7379
- # dirname walk made the twin silently derive the OUTER repo root -- version None,
7380
- # 0 skills, no error (fresh-review HIGH, reproduced by running both copies).
7381
- kit, cur = None, os.path.dirname(here)
8504
+ def _engine_kit_version():
8505
+ """(version, where) for the kit THIS engine came from, or (None, [dirs it looked in]).
8506
+
8507
+ Which kit an installed skill came from is a different question from which kit tree this
8508
+ engine sits in, and an INSTALLED engine has no kit tree at all: `install-uscha.py` copies
8509
+ the kit's version beside the installed skills precisely so the question stays answerable
8510
+ there -- from a checkout this walk lands on the kit root like _kit_root() does, and from an
8511
+ install it lands on the install root.
8512
+
8513
+ THREE sources, in this order at every level (2.2.0, fresh review):
8514
+
8515
+ 1. `.uscha-kit-VERSION` -- the NAMESPACED copy the installer writes. The copy used to be
8516
+ called `VERSION`, a bare shared name dropped into directories the kit does not own
8517
+ (`~/.claude/skills/`, `~/.cursor/skills/`): whatever else lived under that name was
8518
+ overwritten by an install and deleted by an uninstall. The kit owns its prefix and
8519
+ nothing else.
8520
+ 2. `VERSION` -- the KIT ROOT's own file, which is the checkout case and is never written by
8521
+ an install any more. A foreign file under that name is read, not written: reading is what
8522
+ this walk is for, and a wrong version is reported as a version, never as damage.
8523
+ 3. `uscha-install.json`'s `version` -- the install marker, already on the walk, so an
8524
+ install whose copy was removed by hand still answers instead of going UNMEASURED.
8525
+
8526
+ The walk starts at the engine's REALPATH: under `--mode link` the installed skill directory
8527
+ is a link into the kit checkout, and an abspath walk-up climbs the agent's tree rather than
8528
+ the kit's -- so a link install could not resolve its own kit without the copy.
8529
+
8530
+ Never guesses: with no version anywhere it returns the directories it READ, so the report
8531
+ can say where it looked instead of only that it failed."""
8532
+ cur = os.path.dirname(os.path.realpath(__file__))
8533
+ looked = []
7382
8534
  for _ in range(6):
7383
- if os.path.isfile(os.path.join(cur, "VERSION")):
7384
- kit = cur
7385
- break
8535
+ looked.append(cur)
8536
+ for name in (KIT_VERSION_COPY, "VERSION"):
8537
+ try:
8538
+ with open(os.path.join(cur, name), encoding="utf-8") as fh:
8539
+ return fh.read().strip().split()[-1], cur
8540
+ except (OSError, IndexError):
8541
+ pass
8542
+ try:
8543
+ with open(os.path.join(cur, "uscha-install.json"), encoding="utf-8") as fh:
8544
+ declared = (json.load(fh) or {}).get("version")
8545
+ if isinstance(declared, str) and declared.strip():
8546
+ return declared.strip().split()[-1], cur
8547
+ except (OSError, ValueError, AttributeError):
8548
+ pass
7386
8549
  nxt = os.path.dirname(cur)
7387
8550
  if nxt == cur:
7388
8551
  break
7389
8552
  cur = nxt
8553
+ return None, looked
8554
+
8555
+
8556
+ BENCH_DOC = "DIAMOND-BENCH.md"
8557
+ # One generated table row per archetype: `| crud-store | PASS | M1 12/12, ... |`. Anchored at the
8558
+ # line start and on the closing pipe so the per-entry prose below the table ("### guard -- PARTIAL")
8559
+ # cannot be counted twice.
8560
+ _BENCH_ROW = re.compile(r"^\|\s*([A-Za-z0-9][\w.-]*)\s*\|\s*(PASS|PARTIAL|FAIL|PENDING)\s*\|")
8561
+
8562
+
8563
+ def _derive_bench(kit):
8564
+ """The Diamond Bench headline, COUNTED out of the bench's own generated report, or None.
8565
+
8566
+ `DIAMOND-BENCH.md` is written by `qa_ledger.py bench` over the committed fixture and carries
8567
+ the "do not hand-edit" banner: every row in it is a measured run. Re-running the bench here
8568
+ would be the honest derivation and is not affordable -- a full pass is ~650 child processes,
8569
+ and `facts` runs on every suite, every deploy and twice per release. So the fact is counted
8570
+ from the RECORDED verdicts, per archetype, never from the summary sentence beside them and
8571
+ never from a number typed into a document.
8572
+
8573
+ The report lives at the REPO root, one level above the kit: an installed kit has no bench,
8574
+ which is why this returns None instead of guessing. `--check` then reports any claim about it
8575
+ as UNMEASURED rather than letting it pass unexamined."""
8576
+ if not kit:
8577
+ return None
8578
+ for cand in (os.path.join(os.path.dirname(kit), BENCH_DOC),
8579
+ os.path.join(kit, BENCH_DOC)):
8580
+ if not os.path.isfile(cand):
8581
+ continue
8582
+ try:
8583
+ with open(cand, encoding="utf-8-sig") as fh:
8584
+ body = fh.read()
8585
+ except OSError:
8586
+ continue
8587
+ verdicts = []
8588
+ for line in body.split("\n"):
8589
+ m = _BENCH_ROW.match(line.strip())
8590
+ if m:
8591
+ verdicts.append(m.group(2))
8592
+ if not verdicts:
8593
+ continue
8594
+ return {"entries": len(verdicts), "pass": verdicts.count("PASS"),
8595
+ "partial": verdicts.count("PARTIAL"), "fail": verdicts.count("FAIL"),
8596
+ "pending": verdicts.count("PENDING")}
8597
+ return None
8598
+
8599
+
8600
+ def _derive_facts():
8601
+ """Facts derived from the ARTIFACTS themselves, never from prose and never from greps
8602
+ over documentation: the subcommand list comes from introspecting the REAL parser, the
8603
+ skill list from the REAL kit tree, the version from the kit VERSION file, the Diamond
8604
+ headline from the bench's own generated report. No timestamp on purpose: regeneration
8605
+ over an unchanged repo must be byte-identical (AC-SF-01)."""
8606
+ kit = _kit_root()
7390
8607
  if kit is None:
7391
8608
  print("[qa_ledger] facts: no VERSION file found walking up from the engine -- "
7392
8609
  "facts that cannot locate their own kit are not facts.", file=sys.stderr)
@@ -7403,14 +8620,24 @@ def _derive_facts():
7403
8620
  skills = sorted(d for d in os.listdir(sdir)
7404
8621
  if os.path.isfile(os.path.join(sdir, d, "SKILL.md")))
7405
8622
  break
8623
+ bench = _derive_bench(kit)
7406
8624
  return {
7407
8625
  "version": version,
7408
8626
  "subcommands": {"count": len(subs), "list": subs},
7409
8627
  "skills": {"count": len(skills), "list": skills},
8628
+ # null, never a zero: an installed kit ships no bench report, and "0 PASS" would be a
8629
+ # measured-looking answer to a question this tree cannot answer.
8630
+ "diamond": bench,
7410
8631
  "_derivation": {
7411
8632
  "version": "uscha-kit/VERSION",
7412
8633
  "subcommands": "argparse introspection of build_parser()",
7413
8634
  "skills": "SKILL.md inventory under uscha-kit/.claude/skills/",
8635
+ "diamond": ("verdict rows of DIAMOND-BENCH.md, the report `bench` generates over "
8636
+ "uscha-kit/tests/fixtures/diamond-bench (regenerate it with: qa_ledger.py "
8637
+ "bench --dir uscha-kit/tests/fixtures/diamond-bench --out "
8638
+ "DIAMOND-BENCH.md)" if bench else
8639
+ "UNMEASURED: no DIAMOND-BENCH.md beside the kit -- claims about the "
8640
+ "bench headline are reported as undecidable, never as green"),
7414
8641
  "omitted": "stack matrix and REAL/VISION registry: no mechanical "
7415
8642
  "source exists yet -- omitted, not guessed (ADR-012)",
7416
8643
  },
@@ -7440,7 +8667,18 @@ _SPELLED = dict((_spell(n), n) for n in range(1, 100))
7440
8667
  # longest alternative first: an alternation offering "six" before "sixty-three" matches the prefix
7441
8668
  _NUM_ALT = "|".join(sorted(_SPELLED, key=len, reverse=True))
7442
8669
  # the leading \b so that "someone skills" cannot be read as the claim "one skills"
7443
- _COUNT = r"\b(\d+|" + _NUM_ALT + r")\s+"
8670
+ _NUM = r"\b(\d+|" + _NUM_ALT + r")"
8671
+ # HTML splits a claim across elements: the homepage's stat tile reads
8672
+ # `<div class="v">8<small>/12</small></div><div class="k">archetypes regenerate</div>`, so the
8673
+ # count and the noun that gives it meaning are separated by markup rather than by a space. The
8674
+ # gap is therefore whitespace OR tags; `[^<>]` stops one tag from swallowing the rest of the
8675
+ # line, and the repetition is bounded so the gap can never run from one sentence into the next.
8676
+ _GAP = r"(?:\s|<[^<>]{0,80}>){0,8}"
8677
+ _ARCH = r"(?:archetypes?|arquetipos?)"
8678
+ # a claimed count that is NOT the one being rewritten (the other half of a verdict pair)
8679
+ _ANYNUM = r"(?:\d+|" + _NUM_ALT + r")"
8680
+ # what separates `8 PASS` from `4 PARTIAL`: a comma, a middle dot, a slash, spaces, markup
8681
+ _VSEP = r"[^A-Za-z0-9<>]{0,8}" + _GAP
7444
8682
 
7445
8683
 
7446
8684
  def _unspell(token):
@@ -7452,17 +8690,57 @@ _CLAIM_PATTERNS = (
7452
8690
  # (fact key path, regex over one line, needs-context substring or None)
7453
8691
  ("version", r"v(\d+\.\d+\.\d+)", "kit"),
7454
8692
  ("version", r"uscha-kit\s+v?(\d+\.\d+\.\d+)", None),
7455
- ("subcommands.count", _COUNT + r"sub-?comm?ands", None),
7456
- ("subcommands.count", r"(\d+)\s+subcomandos", None),
8693
+ # `_GAP` rather than a plain space since 2.2.0: the site's stat tiles put the count in one
8694
+ # element and its noun in the next (`<div class="v">52</div><div class="k">engine
8695
+ # subcommands</div>`), so a gate that demanded whitespace between them read the homepage's
8696
+ # headline numbers as prose. It had `52 engine subcommands` and `9/12 archetypes` on one
8697
+ # screen, both stale, both invisible to a green release.
8698
+ ("subcommands.count", _NUM + _GAP + r"(?:engine\s+)?sub-?comm?ands", None),
8699
+ ("subcommands.count", r"\b(\d+)" + _GAP + r"subcomandos", None),
7457
8700
  # "agent skills" is the kit's own noun phrase and the paper's; nothing wider is let in,
7458
8701
  # because a WRITER that guessed at "two other skills" would corrupt the sentence it fixed.
7459
- ("skills.count", _COUNT + r"(?:agent\s+)?skills", None),
8702
+ ("skills.count", _NUM + _GAP + r"(?:agent\s+)?skills", None),
8703
+ # The Diamond Bench headline (2.2.0). The homepage said 9/12 for nine releases after ADR-042
8704
+ # moved `transformer` to PARTIAL, because no gate could see the claim: the number sat in one
8705
+ # HTML element and its noun in the next.
8706
+ #
8707
+ # Two shapes only, and both are narrow BY MEASUREMENT -- each wider draft was tried against
8708
+ # the gated set first and rejected by what it caught:
8709
+ #
8710
+ # `<n>/12 archetypes`, `<n> of 12 archetypes`, `<n> de 12 arquetipos` -- the count must be
8711
+ # followed, across markup but never across prose, by the noun it counts, and the
8712
+ # denominator must be the DIGITS 12. That is what keeps the repo's own historical sentence
8713
+ # ("It was 9 of 12 until ADR-042 moved transformer") and the paper's "ten of twelve
8714
+ # archetypes" out of a writer that would have silently rewritten both.
8715
+ #
8716
+ # `<n> PASS <sep> <n> PARTIAL` as ONE shape, verdicts case-sensitive (`(?-i:...)` against
8717
+ # the module-wide re.I). Reading the two numbers independently caught the paper's snapshot
8718
+ # of the July-August arm -- "nine archetypes PASS ... three PARTIAL", a sentence about a
8719
+ # different experiment -- and would have offered to rewrite it. The bench headline always
8720
+ # writes the pair together, so adjacency is both narrower and closer to the real claim.
8721
+ #
8722
+ # `<n> archetypes` alone is deliberately NOT a claim: across the gated set it names subsets
8723
+ # far more often than the bench ("five archetypes", "two archetypes have no second run"), so
8724
+ # the entries count stays a derived fact with no recognised published shape.
8725
+ ("diamond.pass", _NUM + _GAP + r"(?:/|of|de)" + _GAP + r"12" + _GAP + _ARCH, None),
8726
+ ("diamond.pass", _NUM + _GAP + r"(?-i:PASS)" + _VSEP + _ANYNUM + _GAP + r"(?-i:PARTIAL)",
8727
+ None),
8728
+ ("diamond.partial",
8729
+ r"\b" + _ANYNUM + _GAP + r"(?-i:PASS)" + _VSEP + _NUM + _GAP + r"(?-i:PARTIAL)", None),
7460
8730
  )
7461
8731
 
7462
8732
 
7463
8733
  def _fact_value(facts, dotted):
8734
+ """The derived fact behind a claim key, or None when THIS tree cannot derive it.
8735
+
8736
+ Not every fact exists everywhere: an installed kit has no `DIAMOND-BENCH.md`, so
8737
+ `diamond.*` is null there. None is the honest answer and both consumers act on it --
8738
+ `--write` leaves the claim alone (there is nothing to write it to) and `--check` reports it
8739
+ as UNMEASURED. Neither treats an underivable fact as agreement."""
7464
8740
  cur = facts
7465
8741
  for part in dotted.split("."):
8742
+ if not isinstance(cur, dict) or cur.get(part) is None:
8743
+ return None
7466
8744
  cur = cur[part]
7467
8745
  return str(cur)
7468
8746
 
@@ -7553,7 +8831,10 @@ def _write_claims(facts, paths):
7553
8831
  # right to left: an earlier rewrite must not move the offsets of a later one
7554
8832
  for key, start, end, token in sorted(claims, key=lambda c: c[1], reverse=True):
7555
8833
  actual = _fact_value(facts, key)
7556
- if _claim_norm(token) == actual:
8834
+ if actual is None or _claim_norm(token) == actual:
8835
+ # None = this tree cannot derive the fact; there is nothing to rewrite the
8836
+ # claim TO, and inventing one would be the opposite of the gate. The --check
8837
+ # that follows reports it as UNMEASURED.
7557
8838
  continue
7558
8839
  line = line[:start] + _claim_rewrite(token, actual) + line[end:]
7559
8840
  n += 1
@@ -7626,7 +8907,11 @@ def cmd_facts(args):
7626
8907
  # comment spans -- a comment is not a published claim
7627
8908
  for key, _s, _e, claimed in _iter_claims(line):
7628
8909
  actual = _fact_value(facts, key)
7629
- if _claim_norm(claimed) != actual:
8910
+ if actual is None:
8911
+ problems.append((path, n, key, claimed,
8912
+ "UNMEASURED -- this tree derives no such fact "
8913
+ "(see SYSTEM-FACTS _derivation)"))
8914
+ elif _claim_norm(claimed) != actual:
7630
8915
  problems.append((path, n, key, claimed, actual))
7631
8916
  # the parser-surface table (Subcommand/Subcomando header, one `<td class="t">`
7632
8917
  # row per subcommand) is a claim too, just not a numeric one -- a row can go
@@ -7666,9 +8951,12 @@ def cmd_facts(args):
7666
8951
  body = json.dumps(facts, indent=2, ensure_ascii=False, sort_keys=True) + "\n"
7667
8952
  with open(args.out, "w", encoding="utf-8", newline="\n") as fh:
7668
8953
  fh.write(body)
7669
- print("FACTS -> %s: version %s · %d subcommands · %d skills"
8954
+ dia = facts.get("diamond")
8955
+ print("FACTS -> %s: version %s · %d subcommands · %d skills · diamond %s"
7670
8956
  % (args.out, facts["version"], facts["subcommands"]["count"],
7671
- facts["skills"]["count"]))
8957
+ facts["skills"]["count"],
8958
+ ("%d PASS · %d PARTIAL of %d" % (dia["pass"], dia["partial"], dia["entries"])
8959
+ if dia else "UNMEASURED (no %s beside the kit)" % BENCH_DOC)))
7672
8960
 
7673
8961
 
7674
8962
 
@@ -9638,10 +10926,33 @@ def cmd_readiness(args):
9638
10926
  # ingeridos (y 0 rojos). El checkbox es RELATO; el testcase es HECHO.
9639
10927
  ac_ids = [i for i in acc_items if i["id"]]
9640
10928
  ac_tags, stale_reports = _sum_ac_tags(ledger)
10929
+ # ADR-046: a green corpus run is the OTHER way a criterion closes measured. Greenfield has
10930
+ # no old code to characterize, so for the criteria that are about real-world input the only
10931
+ # field evidence there can be is a corpus run over real inputs -- and it closes exactly like
10932
+ # a green testcase does, with the same fail-closed rule below. `corpus_red` is a FAILING
10933
+ # tagged run -- evidence AGAINST, in ADR-046's own words -- and it vetoes like a red
10934
+ # testcase; an ADVISORY run is neither and does neither.
10935
+ corpus_closed, corpus_red = _corpus_ac_verdicts(ledger)
10936
+ # ADR-047: and a green SMOKE check is the third way. "the jar served /admin" used to
10937
+ # arrive as a sub-agent's sentence; now it arrives as a check in a report the engine
10938
+ # READ, and a check named "AC-28 ..." closes AC-28 exactly as a green testcase named
10939
+ # "AC-28 ..." does. `smoke_red` is a FAILED tagged check -- red evidence, and it vetoes
10940
+ # like a red testcase.
10941
+ smoke_closed, smoke_red = _smoke_ac_verdicts(ledger)
9641
10942
 
9642
10943
  def _ac_closed(cid):
9643
10944
  d = ac_tags.get(cid)
9644
- return bool(d and d["green"] >= 1 and d["red"] == 0)
10945
+ # fail-closed FIRST, always: red evidence of ANY kind outranks every green one,
10946
+ # because the cheapest way to fake a closed criterion is to add a green beside a red.
10947
+ if d and d["red"]:
10948
+ return False # red evidence vetoes, whatever else says (fail-closed)
10949
+ if cid in smoke_red:
10950
+ return False # a FAILED tagged smoke check is red evidence too
10951
+ if cid in corpus_red:
10952
+ return False # and so is a FAILING tagged corpus run (ADR-046)
10953
+ if d and d["green"] >= 1:
10954
+ return True
10955
+ return cid in corpus_closed or cid in smoke_closed
9645
10956
 
9646
10957
  # IDs duplicados (ACCEPTANCE mal numerado) cuentan UNA sola vez — si no,
9647
10958
  # un solo test verde cierra "medido" tantos criterios como copias del ID.
@@ -9887,6 +11198,17 @@ def cmd_readiness(args):
9887
11198
  if (acc_traceable and total) else None),
9888
11199
  "narrated_only": narrated_only,
9889
11200
  "measured_unchecked": measured_unchecked,
11201
+ # ADR-046: WHICH ids a green corpus run closed, so a reader can tell
11202
+ # field evidence from suite evidence instead of inferring it -- and
11203
+ # `corpus_vetoed` for the ids a FAILING run holds open, the half a
11204
+ # reader cannot infer from the closed list.
11205
+ "corpus_closed": sorted(corpus_closed, key=_top_ac_key),
11206
+ "corpus_vetoed": sorted(corpus_red, key=_top_ac_key),
11207
+ # ADR-047: the same for the ids a green smoke check closed -- and
11208
+ # `smoke_vetoed` for the ids a FAILED one holds open, which is the
11209
+ # half a reader cannot infer from the closed list.
11210
+ "smoke_closed": sorted(smoke_closed, key=_top_ac_key),
11211
+ "smoke_vetoed": sorted(smoke_red, key=_top_ac_key),
9890
11212
  "stale_reports": stale_reports},
9891
11213
  "facts": {"coverage_pct": round(agg_cov_pct, 2), "coverage_threshold": threshold,
9892
11214
  "gated_open": total_open, "severity": agg_sev,
@@ -9909,9 +11231,32 @@ def cmd_readiness(args):
9909
11231
  # lifecycle (ADR-040): advisory, and CONDITIONAL like fast_path/spec_drift -- a project
9910
11232
  # that declares no lifecycle: block keeps the exact prior payload and the exact prior
9911
11233
  # text. Speaking only when it matters is the anti-ceremony rule applied to itself.
9912
- _lc = _lifecycle_for(os.path.dirname(os.path.abspath(args.ledger)) or os.getcwd())
11234
+ _ready_root = os.path.dirname(os.path.abspath(args.ledger)) or os.getcwd()
11235
+ _lc = _lifecycle_for(_ready_root)
9913
11236
  if _lc["declared"]:
9914
11237
  out["lifecycle"] = _lc
11238
+ # agent-origin (ADR-044): advisory and CONDITIONAL for the same reason -- a project
11239
+ # that tags nothing keeps the exact prior payload and the exact prior text. It never
11240
+ # enters the gates line, never caps the score, never blocks convergence.
11241
+ # field truth (ADR-046): advisory and CONDITIONAL for the same reason -- a project that
11242
+ # declares no corpus and ran none keeps the exact prior payload and the exact prior text.
11243
+ # The `field` DIMENSION and its weight are deliberately NOT here: adding one moves every
11244
+ # existing project's score, and that is its own ADR.
11245
+ _field = _corpus_field(ledger)
11246
+ if _field:
11247
+ out["field"] = _field
11248
+ # smoke (ADR-047): conditional for the same reason. A failing smoke ALREADY blocks
11249
+ # through its `gate:smoke` record; this block adds what the rollup cannot carry --
11250
+ # which checks ran, and which of them answered wrong.
11251
+ _smoke = _smoke_report(ledger)
11252
+ if _smoke:
11253
+ out["smoke"] = _smoke
11254
+ _ao = _agent_origin_report(_ready_root,
11255
+ acc_path if acc_found else None)
11256
+ if _ao["n_unconfirmed"] or _ao["confirmed"]:
11257
+ out["agent_origin"] = {"unconfirmed": _ao["unconfirmed"],
11258
+ "confirmed": _ao["confirmed"],
11259
+ "files_scanned": _ao["files_scanned"]}
9915
11260
  if args.json:
9916
11261
  print(json.dumps(out, indent=2, ensure_ascii=False))
9917
11262
  return
@@ -9945,8 +11290,9 @@ def cmd_readiness(args):
9945
11290
  "or the explicit weight in config.defaults.readiness_weights")
9946
11291
  if narrated_only:
9947
11292
  print(f" ! narrated-only: {', '.join(narrated_only)} — checkbox ticked "
9948
- f"WITHOUT a green 'AC-n' testcase in the reports (measured beats "
9949
- f"narrated: does NOT close)")
11293
+ f"WITHOUT a green 'AC-n' testcase in the reports, without a green "
11294
+ f"corpus run carrying it and without a green 'AC-n' smoke check "
11295
+ f"(measured beats narrated: does NOT close)")
9950
11296
  if measured_unchecked:
9951
11297
  print(f" · measured but unticked: {', '.join(measured_unchecked)} — there is "
9952
11298
  f"a green testcase; tick the checkbox if the criterion is done")
@@ -10027,6 +11373,37 @@ def cmd_readiness(args):
10027
11373
  print(f"--- gates: {n_ok} ok{adv_str} · {len(blocking)} blocking ({names}){hint}")
10028
11374
  else:
10029
11375
  print(f"--- gates: {n_ok} ok{adv_str}, none blocking{hint}")
11376
+ # ADR-046: the FIELD line, one per repo that declares a corpus or has run one. A failing
11377
+ # corpus ALREADY appears in the gates rollup above (it is a gate:corpus record like any
11378
+ # other); this line adds the number the rollup cannot carry -- what percentage of REAL
11379
+ # inputs the system gets right, against which declared budget.
11380
+ for _rname in sorted(_field):
11381
+ print(_corpus_field_line(_rname, _field[_rname]))
11382
+ # ADR-047: the SMOKE line, one per repo that ingested a report. Like the field line it
11383
+ # adds the numbers the gates rollup cannot carry -- how many checks ran, how many
11384
+ # answered wrong, and WHICH ones, so the failure is named instead of counted.
11385
+ for _rname in sorted(_smoke):
11386
+ print(_smoke_line(_rname, _smoke[_rname]))
11387
+ # ADR-044: its OWN line, deliberately outside the gates rollup. An unconfirmed
11388
+ # agent-origin item is a decision still owed to the human, not a gate that ran --
11389
+ # folding it into "N ok" or into "N blocking" would be the false clean ADR-043
11390
+ # refused, in the other direction. It caps nothing and blocks nothing.
11391
+ if _ao["n_unconfirmed"]:
11392
+ print(f"--- origin: {_ao['n_unconfirmed']} agent-origin item(s) unconfirmed"
11393
+ f" (spec-check names them)")
11394
+ # ADR-048: operability gets its own line, CONDITIONAL on a record existing -- a ledger that
11395
+ # never ran the check prints exactly what it printed before. The gates rollup above already
11396
+ # counts the record correctly (ok / blocking / advisory); what it cannot say is WHICH of the
11397
+ # four is the one to go and build, and "1 blocking (backend-api/gate:operability)" is
11398
+ # precisely the message that sends a human to read the source.
11399
+ _ops = [(rname, _latest_static_by_tool(rnode).get("gate:operability"))
11400
+ for rname, rnode in ledger["repos"].items()]
11401
+ _ops = [(rname, rec) for rname, rec in _ops if rec and rec.get("note")]
11402
+ for _rname, _op in _ops:
11403
+ _label = "operability" if len(_ops) == 1 else "operability %s" % _rname
11404
+ _state = (" (advisory)" if _op.get("advisory")
11405
+ else " (gate: FAIL)" if (_op.get("gated_reported") or 0) else " (gate)")
11406
+ print(f"--- {_label}: {_op['note']}{_state}")
10030
11407
  if not args.verbose:
10031
11408
  return
10032
11409
  print("--- dimensions (weight | raw | contribution) ---")
@@ -10317,18 +11694,25 @@ def _rebuild_compare(args):
10317
11694
  # --------------------------------------------------------------------------- #
10318
11695
  # simplicity-check (the "Reduce" gate)
10319
11696
  # --------------------------------------------------------------------------- #
10320
- def _read_diff(args):
10321
- """Unified-diff text from --diff, --from-git, or stdin."""
11697
+ def _read_diff(args, detect_renames=False):
11698
+ """Unified-diff text from --diff, --from-git, or stdin.
11699
+
11700
+ detect_renames (2.2.0) adds `-M` to the `--from-git` command so git reports a rename AS a
11701
+ rename, whatever the caller's `diff.renames` config says. It is OPT-IN because the other
11702
+ readers of this helper count LINES (simplicity, waste, regression): collapsing a rename into
11703
+ a header would silently change the numbers they have been measuring for releases. gate-check
11704
+ is the caller that needs it -- a rename read as a delete/add pair is what made
11705
+ `git mv tests/a_test.py tests/b_test.py` block as a deleted test in the field."""
10322
11706
  if getattr(args, "diff", None):
10323
11707
  with open(args.diff, "r", encoding="utf-8", errors="replace") as fh:
10324
11708
  return fh.read()
10325
11709
  if getattr(args, "from_git", False):
10326
11710
  import subprocess
10327
11711
  base = args.base or "HEAD"
11712
+ cmd = ["git", "diff", "--unified=0"] + (["-M"] if detect_renames else []) + [base]
10328
11713
  try:
10329
11714
  return subprocess.run(
10330
- ["git", "diff", "--unified=0", base],
10331
- check=True, capture_output=True, text=True,
11715
+ cmd, check=True, capture_output=True, text=True,
10332
11716
  encoding="utf-8", errors="replace").stdout
10333
11717
  except Exception as exc: # noqa: BLE001
10334
11718
  print(f"[qa_ledger] git diff failed: {exc}", file=sys.stderr)
@@ -11088,8 +12472,125 @@ def _gc_new_dep(path, body):
11088
12472
  return bool(rx.search(body)) if rx else False
11089
12473
 
11090
12474
 
12475
+ def _gc_moves(diff):
12476
+ """Renames read as MOVES, never as deletions (2.2.0 field fix).
12477
+
12478
+ `git mv tests/a_test.py tests/b_test.py` used to be reported as a deleted test -- a BLOCKER
12479
+ and exit 1 for a change that deleted nothing. A rename reaches this parser in one of two
12480
+ shapes, and both are read here:
12481
+
12482
+ * git's own `rename from` / `rename to` headers, present when the producer detected
12483
+ renames (`--from-git` now forces `-M`, so the caller's `diff.renames` config can no
12484
+ longer hide one); and
12485
+ * an EXACT delete/add pair -- the same file content leaving one path and arriving at
12486
+ another inside the same diff. That is what a producer with rename detection OFF emits,
12487
+ and it is the shape that actually blocked in the field.
12488
+
12489
+ Returns (moves, paired). `moves` is the informational report. `paired` holds the paths of
12490
+ the exact pairs ONLY: their hunks say nothing about the change and are skipped. A rename
12491
+ WITH edits keeps its hunks, because moving a file is not a deletion but deleting a test out
12492
+ of a moved file still is -- and that verdict must not change.
12493
+
12494
+ The pairing is deliberately EXACT and one-to-one: same content, one file losing it, one file
12495
+ gaining it. Two deleted files with identical bodies are ambiguous, so neither is paired --
12496
+ guessing which moved where would be inventing a fact to clear a gate, which is the one thing
12497
+ this gate exists to refuse."""
12498
+ moves = []
12499
+ deleted, added = {}, {} # path -> tuple of line bodies
12500
+ path = None # the whole-file side currently being collected
12501
+ side = None # "-" while inside a deletion, "+" inside an addition
12502
+ bodies = []
12503
+ minus_path = None
12504
+
12505
+ def _flush():
12506
+ if path is not None and bodies:
12507
+ (deleted if side == "-" else added)[path] = tuple(bodies)
12508
+
12509
+ for raw in diff.splitlines():
12510
+ if raw.startswith("diff --git"):
12511
+ _flush()
12512
+ path, side, bodies, minus_path = None, None, [], None
12513
+ continue
12514
+ if raw.startswith("rename from "):
12515
+ minus_path = raw[len("rename from "):].strip()
12516
+ continue
12517
+ if raw.startswith("rename to "):
12518
+ if minus_path:
12519
+ moves.append("%s -> %s" % (minus_path, raw[len("rename to "):].strip()))
12520
+ minus_path = None
12521
+ continue
12522
+ if raw.startswith("--- "):
12523
+ p = raw[4:].strip().split("\t")[0]
12524
+ if p == "/dev/null":
12525
+ side = "+"
12526
+ else:
12527
+ minus_path = p[2:] if p[:2] in ("a/", "b/") else p
12528
+ continue
12529
+ if raw.startswith("+++ "):
12530
+ p = raw[4:].strip().split("\t")[0]
12531
+ if p == "/dev/null":
12532
+ side, path = "-", minus_path
12533
+ elif side == "+":
12534
+ path = p[2:] if p[:2] in ("a/", "b/") else p
12535
+ else:
12536
+ path, side = None, None # an ordinary edit: neither half of a move
12537
+ bodies = []
12538
+ continue
12539
+ if path is not None and side and raw.startswith(side):
12540
+ bodies.append(raw[1:])
12541
+ _flush()
12542
+
12543
+ paired = set()
12544
+ for dpath, content in deleted.items():
12545
+ hits = [a for a, c in added.items() if c == content]
12546
+ if len(hits) != 1:
12547
+ continue
12548
+ if sum(1 for c in deleted.values() if c == content) != 1:
12549
+ continue
12550
+ moves.append("%s -> %s" % (dpath, hits[0]))
12551
+ paired.add(dpath)
12552
+ paired.add(hits[0])
12553
+ return sorted(set(moves)), paired
12554
+
12555
+
12556
+ def _gc_scope(args, ledger):
12557
+ """`--repo R` SCOPES the diff to the files under repos[R].path (2.2.0 field fix).
12558
+
12559
+ In a monorepo one `git diff` carries every repo's hunks, and gate-check reported all of them
12560
+ under whichever repo was named: a fact about someone ELSE's code, attributed to yours, with
12561
+ your exit code behind it. Returns (base, scope) as absolute directories, or None when there
12562
+ is nothing to scope by (no --repo, or a scope that is the whole tree).
12563
+
12564
+ realpath on BOTH sides before comparing. On Windows a path under a username longer than 8
12565
+ characters comes back short-formed (`RUNNER~1`) from one API and long-formed from another,
12566
+ and a file INSIDE the tree is then judged outside it -- the CI-only failure this repo has
12567
+ already paid for once. The scope directory is realpath'd; the diff path is joined onto an
12568
+ already-realpath'd base rather than realpath'd itself, because a DELETED file no longer
12569
+ exists and would resolve inconsistently."""
12570
+ if ledger is None or not getattr(args, "repo", None):
12571
+ return None
12572
+ base = os.path.realpath(os.path.dirname(os.path.abspath(args.ledger)) or ".")
12573
+ scope = os.path.realpath(os.path.join(base, _scope_path(ledger, args.repo)))
12574
+ return None if scope == base else (base, scope)
12575
+
12576
+
12577
+ def _gc_in_scope(path, scope):
12578
+ if scope is None:
12579
+ return True
12580
+ base, root = scope
12581
+ full = os.path.normpath(os.path.join(base, path.replace("/", os.sep)))
12582
+ return full == root or full.startswith(root + os.sep)
12583
+
12584
+
11091
12585
  def cmd_gate_check(args):
11092
- diff = _read_diff(args)
12586
+ # --repo now does TWO things, and both need the ledger: it scopes the diff to that repo's
12587
+ # path (_gc_scope) and it adds the measured snapshot cross-check below. Loading it here
12588
+ # keeps the existing behaviour of an unreadable ledger or an unknown repo name exiting 2
12589
+ # rather than being scoped to nothing in silence.
12590
+ ledger = _load(args.ledger) if getattr(args, "repo", None) else None
12591
+ scope = _gc_scope(args, ledger)
12592
+ diff = _read_diff(args, detect_renames=True)
12593
+ moves, paired = _gc_moves(diff)
11093
12594
  removed_tests, disabled_tests, suppressions, thresholds = [], [], [], []
11094
12595
  secrets, secret_literals, scrub_edits, new_deps = [], [], [], []
11095
12596
  assertions_removed = 0
@@ -11120,12 +12621,16 @@ def cmd_gate_check(args):
11120
12621
  path = minus_path
11121
12622
  else:
11122
12623
  path = p[2:] if p[:2] in ("a/", "b/") else p
11123
- if _GC_KEYFILE.search(path):
11124
- secrets.append(f"{path}: contenedor de claves agregado/modificado")
12624
+ if path is not None and (path in paired or not _gc_in_scope(path, scope)):
12625
+ # a MOVED half (one side of an exact delete/add pair) and a file outside
12626
+ # --repo's scope are not this run's business: their hunks are skipped whole.
12627
+ path = None
12628
+ elif path is not None and p != "/dev/null" and _GC_KEYFILE.search(path):
12629
+ secrets.append(f"{path}: contenedor de claves agregado/modificado")
11125
12630
  elif raw.startswith("Binary files "):
11126
12631
  # los .p12/.jks binarios no traen +++ — el lado b/ vive en esta linea
11127
12632
  m = re.search(r" and b/(.+) differ$", raw)
11128
- if m and _GC_KEYFILE.search(m.group(1)):
12633
+ if m and _GC_KEYFILE.search(m.group(1)) and _gc_in_scope(m.group(1), scope):
11129
12634
  secrets.append(f"{m.group(1)}: contenedor de claves agregado/modificado (binario)")
11130
12635
  continue
11131
12636
  if not path:
@@ -11186,9 +12691,8 @@ def cmd_gate_check(args):
11186
12691
  # optional MEASURED cross-check (heuristic-independent): with --repo, compare the
11187
12692
  # last two snapshots' executed-test totals — a drop is a fact no regex can miss.
11188
12693
  test_count_drop = None
11189
- if getattr(args, "repo", None):
12694
+ if ledger is not None:
11190
12695
  try:
11191
- ledger = _load(args.ledger)
11192
12696
  node = _repo_node(ledger, args.repo)
11193
12697
  snaps = node.get("snapshots", [])
11194
12698
  if len(snaps) >= 2:
@@ -11222,10 +12726,15 @@ def cmd_gate_check(args):
11222
12726
  "new_dependencies": sorted(set(new_deps)),
11223
12727
  "assertions_removed": assertions_removed,
11224
12728
  "test_count_drop": test_count_drop,
12729
+ # informational, and deliberately OUTSIDE hard/soft: a move is neither a finding
12730
+ # nor an absolution, it is the reason a deletion is not being reported.
12731
+ "moved": moves,
12732
+ "scope": (os.path.basename(scope[1]) if scope else None),
11225
12733
  }, indent=2, ensure_ascii=False))
11226
12734
  sys.exit(1 if blocker else 0)
11227
12735
 
11228
- print(f"GATE-INTEGRITY: {verdict}")
12736
+ print(f"GATE-INTEGRITY: {verdict}"
12737
+ + (f" (scoped to {args.repo})" if scope else ""))
11229
12738
 
11230
12739
  def _show(label, items):
11231
12740
  if items:
@@ -11247,6 +12756,10 @@ def cmd_gate_check(args):
11247
12756
  print(f" ~ asserts removed from tests: {assertions_removed} (review)")
11248
12757
  if test_count_drop:
11249
12758
  print(f" ~ executed-test count dropped: {test_count_drop} (measured in snapshots — review)")
12759
+ if moves:
12760
+ tail = " ..." if len(moves) > 5 else ""
12761
+ print(f" . files moved: {len(moves)} — {'; '.join(moves[:5])}{tail} "
12762
+ f"(informational: a rename is not a deletion)")
11250
12763
  if verdict == "CLEAN":
11251
12764
  print(" the change does not weaken the measuring apparatus")
11252
12765
  elif not blocker:
@@ -11639,6 +13152,142 @@ def _lifecycle_for(root, adr_dir=None, spec_text=None, fallback=True):
11639
13152
  return _lifecycle_report(adr_dir or os.path.join(root, "docs", "adr"), spec_text)
11640
13153
 
11641
13154
 
13155
+ # --------------------------------------------------------------------------- #
13156
+ # agent-origin markers (ADR-044): the agent asks for DECISIONS, never for INFORMATION
13157
+ # --------------------------------------------------------------------------- #
13158
+ # A decision the human never made must not enter scope by rebound. Every acceptance
13159
+ # criterion, ADR decision item or HANDOFF rule the AGENT introduced carries one trailing
13160
+ # marker on its own line:
13161
+ #
13162
+ # (origin: agent) introduced by the agent, NOT confirmed
13163
+ # (origin: agent, confirmed: YYYY-MM-DD) a human confirmed THIS item, on that day
13164
+ #
13165
+ # No marker = human origin. That is the default on purpose: nothing existing is
13166
+ # retro-tagged, so the absence of a marker never has to be re-audited.
13167
+ #
13168
+ # ADVISORY, always. This section never changes an exit code and never caps readiness --
13169
+ # it reports what has not been confirmed yet, and the human decides. A gate here would
13170
+ # need an adopted budget (the 2.1.0 posture, ADR-043), and nobody has declared one.
13171
+ #
13172
+ # A marker inside a fenced block or an inline code span is DOCUMENTATION, not a decision:
13173
+ # the ADR and the ACCEPTANCE section that DEFINE this grammar quote it, and a scanner that
13174
+ # read its own definition as a finding would be measuring its own prose.
13175
+ _AO_MARK_RX = re.compile(r"origin:\s*agent\b(?P<rest>[^)\n]*)", re.I)
13176
+ _AO_CONF_RX = re.compile(r"confirmed:\s*(?P<value>[^,)\s]*)", re.I)
13177
+ _AO_ID_RX = re.compile(r"^[\s>*+-]*(?:\[[ xX]\]\s*)?(?:\d+[.)]\s*)?[*_]*"
13178
+ r"(?P<id>[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)")
13179
+ _AO_INLINE_CODE_RX = re.compile(r"`[^`]*`")
13180
+ _AO_HTML_COMMENT_RX = re.compile(r"<!--.*?-->")
13181
+
13182
+
13183
+ def _agent_origin_scan_text(text, label):
13184
+ """Every agent-origin marker in one markdown file. Returns (unconfirmed, confirmed):
13185
+ a list of {id, file, line, malformed, detail} and a count. A `confirmed:` that is not
13186
+ a real YYYY-MM-DD date counts as UNCONFIRMED and is NAMED -- a typo must never read as
13187
+ a human's approval, which is the one failure this marker exists to prevent."""
13188
+ lines = text.split("\n")
13189
+ # A fence that never closes is a typo, not a decision to hide the rest of the file:
13190
+ # the lines after an unmatched opener are scanned as prose. Every OTHER fence still
13191
+ # hides its body, so the grammar's own definitions stay documentation.
13192
+ fenced, in_fence, opener = [False] * len(lines), False, None
13193
+ for idx, raw in enumerate(lines):
13194
+ st = raw.strip()
13195
+ if st.startswith("```") or st.startswith("~~~"):
13196
+ in_fence = not in_fence
13197
+ opener = idx if in_fence else None
13198
+ fenced[idx] = True
13199
+ continue
13200
+ fenced[idx] = in_fence
13201
+ if in_fence and opener is not None:
13202
+ for idx in range(opener, len(lines)):
13203
+ fenced[idx] = False
13204
+ unconfirmed, confirmed = [], 0
13205
+ for i, raw in enumerate(lines, 1):
13206
+ if fenced[i - 1]:
13207
+ continue
13208
+ line = _AO_HTML_COMMENT_RX.sub("", _AO_INLINE_CODE_RX.sub("", raw))
13209
+ idm = _AO_ID_RX.match(line)
13210
+ item = idm.group("id") if idm else "line %d" % i
13211
+ # every marker on the line, not the first: a confirmation appended after the
13212
+ # original tag must be read, never dropped in silence
13213
+ for m in _AO_MARK_RX.finditer(line):
13214
+ cm = _AO_CONF_RX.search(m.group("rest") or "")
13215
+ if cm is None:
13216
+ unconfirmed.append({"id": item, "file": label, "line": i,
13217
+ "malformed": False, "detail": None})
13218
+ elif _lc_valid_date(cm.group("value")):
13219
+ confirmed += 1
13220
+ else:
13221
+ unconfirmed.append({"id": item, "file": label, "line": i, "malformed": True,
13222
+ "detail": "malformed confirmed: %s"
13223
+ % (cm.group("value") or "(empty)")})
13224
+ return unconfirmed, confirmed
13225
+
13226
+
13227
+ def _agent_origin_report(root, acceptance=None, adr_dir=None, extra=()):
13228
+ """The advisory dimension over the files that hold decisions: the ACCEPTANCE file
13229
+ (the one named, else `<root>/ACCEPTANCE.md`), every ADR under `adr_dir`, `HANDOFF.md`
13230
+ when present, and whatever the caller already had open (`extra`). Absent files are
13231
+ simply not scanned -- there is nothing to report about a file that does not exist."""
13232
+ paths, seen = [], set()
13233
+
13234
+ def add(p):
13235
+ if not p:
13236
+ return
13237
+ try:
13238
+ key = os.path.realpath(p)
13239
+ except OSError:
13240
+ key = os.path.abspath(p)
13241
+ if key in seen or not os.path.isfile(p):
13242
+ return
13243
+ seen.add(key)
13244
+ paths.append(p)
13245
+
13246
+ for p in extra:
13247
+ add(p)
13248
+ add(acceptance or os.path.join(root, "ACCEPTANCE.md"))
13249
+ add(os.path.join(root, "HANDOFF.md"))
13250
+ adr = adr_dir or os.path.join(root, "docs", "adr")
13251
+ if os.path.isdir(adr):
13252
+ for f in sorted(glob.glob(os.path.join(adr, "*.md"))):
13253
+ add(f)
13254
+ unconfirmed, confirmed, scanned = [], 0, []
13255
+ for p in paths:
13256
+ try:
13257
+ with open(p, "r", encoding="utf-8", errors="replace") as fh:
13258
+ body = fh.read()
13259
+ except OSError:
13260
+ continue # unreadable is not a finding; it is simply not scanned
13261
+ # forward slashes always: the same tree must name the same file the same way on
13262
+ # Windows and on the CI cells, or a pinned line differs by separator alone.
13263
+ label = _lc_short(p).replace("\\", "/")
13264
+ scanned.append(label)
13265
+ u, c = _agent_origin_scan_text(body, label)
13266
+ unconfirmed += u
13267
+ confirmed += c
13268
+ return {"unconfirmed": unconfirmed, "confirmed": confirmed,
13269
+ "n_unconfirmed": len(unconfirmed), "files_scanned": scanned}
13270
+
13271
+
13272
+ def _agent_origin_names(ao, limit=6):
13273
+ """`AC-07 (ACCEPTANCE.md:41), D-03 (docs/adr/ADR-002-x.md:57)` -- the id, where it is,
13274
+ and for a malformed marker WHY it did not count as confirmed."""
13275
+ items = ao["unconfirmed"]
13276
+ out = ", ".join("%s (%s:%d%s)" % (x["id"], x["file"], x["line"],
13277
+ ", " + x["detail"] if x["detail"] else "")
13278
+ for x in items[:limit])
13279
+ if len(items) > limit:
13280
+ out += " +%d more" % (len(items) - limit)
13281
+ return out
13282
+
13283
+
13284
+ def _agent_origin_line(ao):
13285
+ """The one advisory line, shared by spec-check and readiness so the two surfaces
13286
+ cannot drift apart."""
13287
+ return ("origin: %d agent-origin item(s) unconfirmed -- %s"
13288
+ % (ao["n_unconfirmed"], _agent_origin_names(ao)))
13289
+
13290
+
11642
13291
  def _spec_check_text(text):
11643
13292
  lines = text.split("\n")
11644
13293
  n = len(lines)
@@ -11934,10 +13583,14 @@ def cmd_spec_check(args):
11934
13583
  else {"blockers": [], "untestable": [], "stack_hits": [],
11935
13584
  "non_ears": 0, "n_criteria": 0})
11936
13585
  # lifecycle (ADR-040): read-only and advisory -- it never touches `fail` below.
11937
- lc = _lifecycle_for(_lifecycle_root(args.spec[0] if args.spec else None,
11938
- args.acceptance),
11939
- getattr(args, "adr_dir", None), text,
13586
+ _root = _lifecycle_root(args.spec[0] if args.spec else None, args.acceptance)
13587
+ lc = _lifecycle_for(_root, getattr(args, "adr_dir", None), text,
11940
13588
  fallback=not args.spec)
13589
+ # agent-origin (ADR-044): read-only and advisory -- like lifecycle above, it never
13590
+ # touches `fail` below. An unconfirmed item is not a defect; it is a decision still
13591
+ # owed to the human.
13592
+ ao = _agent_origin_report(_root, args.acceptance,
13593
+ getattr(args, "adr_dir", None), extra=args.spec or ())
11941
13594
  structural = len(m["blockers"]) + len(acc_block) # estructura = FACT -> bloquea
11942
13595
  soft_find = len(m["untestable"]) + len(m["stack_hits"]) + len(acc_adv)
11943
13596
  fail = structural > 0 or (args.strict and soft_find > 0)
@@ -11946,7 +13599,9 @@ def cmd_spec_check(args):
11946
13599
  if args.json:
11947
13600
  print(json.dumps({"verdict": verdict, "advisory": structural == 0,
11948
13601
  "acceptance_blockers": acc_block,
11949
- "acceptance_advisory": acc_adv, "lifecycle": lc, **m},
13602
+ "acceptance_advisory": acc_adv, "lifecycle": lc,
13603
+ "agent_origin": {"unconfirmed": ao["unconfirmed"],
13604
+ "confirmed": ao["confirmed"]}, **m},
11950
13605
  indent=2, ensure_ascii=False))
11951
13606
  sys.exit(1 if fail else 0)
11952
13607
 
@@ -11972,6 +13627,10 @@ def cmd_spec_check(args):
11972
13627
  print(" %s %s %s - %s (%s)"
11973
13628
  % ("!" if c["status"] == "expires before go-live" else "~",
11974
13629
  c["component"], c.get("version") or "?", c["status"], c["detail"]))
13630
+ # conditional, like every other advisory line here: a tree with no marker prints
13631
+ # exactly what it printed before this release.
13632
+ if ao["n_unconfirmed"]:
13633
+ print(" ~ " + _agent_origin_line(ao))
11975
13634
  print(" i consistency: INFERENTIAL (an uncorrelated checker), not this lint · "
11976
13635
  "structure = FACT (blocks) · prose = advisory (--strict to gate)")
11977
13636
  if verdict == "OK":
@@ -12326,6 +13985,91 @@ def _doctor_hook_registered(settings_path):
12326
13985
  return next((n for n in HOOK_NAMES if n in blob), None)
12327
13986
 
12328
13987
 
13988
+ # --------------------------------------------------------------------------- #
13989
+ # installed-skill freshness (2.2.0)
13990
+ # --------------------------------------------------------------------------- #
13991
+ # The field case: skills sat under ~/.claude/skills/uscha-* dated before 1.54.0 while the kit
13992
+ # was 1.97.0. A whole discovery ran on the old prose and nothing said a word, because a SKILL.md
13993
+ # carried no version to compare. Since 2.2.0 the GENERATED orientation block opens with
13994
+ # `<!-- uscha kit: X.Y.Z ... -->` (tools/skill-blocks/, rendered by tools/gen-skill-blocks.py and
13995
+ # re-rendered by tools/release.py at every bump), so the comparison is mechanical.
13996
+ #
13997
+ # ADVISORY, always: this reports, it never gates. `doctor` already exits 1 only on errors, and an
13998
+ # outdated install is a WARN -- the operator may be pinning a version on purpose.
13999
+ SKILL_KIT_MARK = re.compile(r"uscha kit:\s*(\d+\.\d+\.\d+)")
14000
+ # Where install-uscha.py puts the skills: TARGETS = ("codex", "claude") + SKILL_ROOTS. Retyped
14001
+ # here rather than imported because install-uscha.py lives at the KIT ROOT and is not installed
14002
+ # alongside the engine -- an installed engine could not import it. Kept in one place so the
14003
+ # drift, if it ever happens, is one table against one table.
14004
+ SKILL_INSTALL_ROOTS = (
14005
+ ("claude", (".claude", "skills")),
14006
+ ("codex", ("plugins", "uscha", "skills")),
14007
+ ("pi", (".agents", "skills")),
14008
+ ("cursor", (".cursor", "skills")),
14009
+ ("copilot", (".copilot", "skills")),
14010
+ ("gemini", (".gemini", "skills")),
14011
+ ("cline", (".cline", "skills")),
14012
+ )
14013
+
14014
+
14015
+ def _semver_tuple(text):
14016
+ """(major, minor, patch) for comparison, or None when the string is not one."""
14017
+ m = re.match(r"^(\d+)\.(\d+)\.(\d+)$", (text or "").strip())
14018
+ return tuple(int(g) for g in m.groups()) if m else None
14019
+
14020
+
14021
+ def _installed_skill_report(root, kit_version):
14022
+ """One install root's uscha-* skills, each with the kit version its block was stamped with.
14023
+
14024
+ Returns None when the root holds no uscha skill at all -- "not installed" is a state, not a
14025
+ fault, and reporting it as an error would make `doctor` red on every machine that installed
14026
+ for one agent out of seven."""
14027
+ if not os.path.isdir(root):
14028
+ return None
14029
+ want = _semver_tuple(kit_version)
14030
+ found, oldest = [], None
14031
+ for name in USCHA_SKILLS:
14032
+ smd = os.path.join(root, name, "SKILL.md")
14033
+ if not os.path.isfile(smd):
14034
+ continue
14035
+ try:
14036
+ with open(smd, encoding="utf-8", errors="replace") as fh:
14037
+ head = fh.read(8192)
14038
+ except OSError:
14039
+ head = ""
14040
+ m = SKILL_KIT_MARK.search(head)
14041
+ seen = m.group(1) if m else None
14042
+ found.append({"skill": name, "installed": seen})
14043
+ got = _semver_tuple(seen)
14044
+ if got is not None and (oldest is None or got < oldest):
14045
+ oldest = got
14046
+ if not found:
14047
+ return None
14048
+ unmarked = [f["skill"] for f in found if f["installed"] is None]
14049
+ if unmarked:
14050
+ # no marker at all = a block rendered before 2.2.0. Older than anything that carries
14051
+ # one, and said that way rather than as a version nobody wrote.
14052
+ status = "outdated"
14053
+ installed = None
14054
+ elif want is None or oldest is None:
14055
+ status = "unknown"
14056
+ installed = ".".join(str(p) for p in oldest) if oldest else None
14057
+ else:
14058
+ installed = ".".join(str(p) for p in oldest)
14059
+ status = "outdated" if oldest < want else "current"
14060
+ return {"root": root, "status": status, "installed": installed, "kit": kit_version,
14061
+ "skills": found, "unmarked": unmarked}
14062
+
14063
+
14064
+ def _installed_skill_roots(args):
14065
+ """(target, root) pairs to inspect: the caller's `--installed` when given, else every root
14066
+ the installer knows, under the user's home."""
14067
+ if getattr(args, "installed", None):
14068
+ return [("--installed", d) for d in args.installed]
14069
+ home = os.path.expanduser("~")
14070
+ return [(t, os.path.join(home, *parts)) for t, parts in SKILL_INSTALL_ROOTS]
14071
+
14072
+
12329
14073
  def cmd_doctor(args):
12330
14074
  checks = [] # (nivel 'ok'|'warn'|'error', titulo, detalle)
12331
14075
 
@@ -12395,6 +14139,45 @@ def cmd_doctor(args):
12395
14139
  if mismatched:
12396
14140
  err(f"SKILL.md con frontmatter name distinto al directorio: {', '.join(mismatched)}")
12397
14141
 
14142
+ # --- installed skills vs the kit's VERSION (2.2.0) ----------------------
14143
+ # A discovery once ran on prose from 1.54.0 while the kit was 1.97.0, and nothing said so.
14144
+ # Advisory: reported as a warning, never an error, so it can never fail an installation
14145
+ # someone pinned on purpose.
14146
+ kit_version, version_src = _engine_kit_version()
14147
+ skills_installed = []
14148
+ absent = []
14149
+ for target, sroot in _installed_skill_roots(args):
14150
+ rep = _installed_skill_report(sroot, kit_version)
14151
+ if rep is None:
14152
+ absent.append((target, sroot))
14153
+ skills_installed.append({"target": target, "root": sroot,
14154
+ "status": "not installed", "installed": None,
14155
+ "kit": kit_version, "skills": [], "unmarked": []})
14156
+ continue
14157
+ rep["target"] = target
14158
+ skills_installed.append(rep)
14159
+ fix = (f"re-install: python install-uscha.py install --target {target} "
14160
+ f"(or `uscha install`)")
14161
+ if rep["status"] == "outdated":
14162
+ shown = rep["installed"] or "no `kit:` marker (block rendered before 2.2.0)"
14163
+ warn(f"SKILLS OUTDATED at {sroot}: installed {shown} < kit {kit_version}", fix)
14164
+ elif rep["status"] == "current":
14165
+ ok(f"skills {target}: current (kit {kit_version})", sroot)
14166
+ else:
14167
+ warn(f"skills {target}: version UNMEASURED at {sroot}",
14168
+ "the installed blocks carry a marker this engine cannot compare "
14169
+ f"(installed {rep['installed']!r}, kit {kit_version!r})")
14170
+ if absent:
14171
+ ok("skills not installed for: " + ", ".join(t for t, _ in absent),
14172
+ "not an error -- the kit installs per agent, one target at a time")
14173
+ if kit_version is None:
14174
+ warn("kit version not readable: installed-skill freshness is UNMEASURED",
14175
+ "the comparison needs a `%s`, a `VERSION` or an `uscha-install.json` at or "
14176
+ "above the engine; looked in: " % KIT_VERSION_COPY
14177
+ + ", ".join(version_src or []) + " -- re-install with "
14178
+ "`python install-uscha.py install` (2.2.0 and later copy it beside the "
14179
+ "installed skills)")
14180
+
12398
14181
  # --- hook INV-GOLDEN-01 -------------------------------------------------
12399
14182
  kit_root = os.path.abspath(os.path.join(engine_dir, "..", "..", ".."))
12400
14183
  hook_dirs = [os.path.join(os.path.expanduser("~"), ".claude", "hooks"),
@@ -12571,6 +14354,10 @@ def cmd_doctor(args):
12571
14354
  # effective settings + origin per knob (2.0.0); null when there is
12572
14355
  # no project config here to resolve them from
12573
14356
  "risk_profile": risk_profile, "effective": effective,
14357
+ # installed-skill freshness (2.2.0): one row per install root the
14358
+ # installer knows, with BOTH versions -- advisory, never in the verdict
14359
+ "kit_version": kit_version,
14360
+ "skills_installed": skills_installed,
12574
14361
  "checks": [{"level": lv, "title": t, "detail": d}
12575
14362
  for lv, t, d in checks]},
12576
14363
  indent=2, ensure_ascii=True))
@@ -12599,6 +14386,11 @@ def build_parser():
12599
14386
  "python/git, skills, hook, project config, per-repo toolchains")
12600
14387
  pdoc.add_argument("--config", default=None,
12601
14388
  help="project config to inspect (default: ./uscha.config.json)")
14389
+ pdoc.add_argument("--installed", action="append", default=None, metavar="DIR",
14390
+ help="skill install root to compare against the kit's VERSION "
14391
+ "(repeatable; default: every root install-uscha.py writes to, "
14392
+ "under ~). Advisory -- an outdated install is a warning, never "
14393
+ "an error")
12602
14394
  pdoc.add_argument("--ledger", default=DEFAULT_LEDGER)
12603
14395
  pdoc.add_argument("--json", action="store_true")
12604
14396
  pdoc.set_defaults(func=cmd_doctor)
@@ -12620,9 +14412,21 @@ def build_parser():
12620
14412
  pri.add_argument("--json", action="store_true")
12621
14413
  pri.set_defaults(func=cmd_rubric_ingest)
12622
14414
 
12623
- pi = sub.add_parser("init", help="create the ledger from a config file")
12624
- pi.add_argument("--config", required=True)
14415
+ pi = sub.add_parser("init", help="create the ledger from a config file, or --add-repo one "
14416
+ "repo into an existing ledger")
14417
+ pi.add_argument("--config", default=None,
14418
+ help="the uscha.config.json to freeze into a NEW ledger (required unless "
14419
+ "--add-repo); with --add-repo it is the file to mirror the new repo "
14420
+ "into (default: uscha.config.json next to --out)")
12625
14421
  pi.add_argument("--out", default=DEFAULT_LEDGER)
14422
+ pi.add_argument("--add-repo", dest="add_repo", default=None, metavar="NAME",
14423
+ help="append ONE repo to the EXISTING ledger at --out instead of creating a "
14424
+ "new one: every existing repo's steps, snapshots and iterations are "
14425
+ "left untouched and the checksum is re-sealed")
14426
+ pi.add_argument("--path", default=None, help="(with --add-repo) the new repo's path")
14427
+ pi.add_argument("--type", default=None, help="(with --add-repo) the new repo's type")
14428
+ pi.add_argument("--test-command", dest="test_command", default=None,
14429
+ help="(with --add-repo) the new repo's test command")
12626
14430
  pi.set_defaults(func=cmd_init)
12627
14431
 
12628
14432
  def add_ledger(sp):
@@ -12968,6 +14772,15 @@ def build_parser():
12968
14772
  help="override defaults.spec_drift.max_lag_days (default 30)")
12969
14773
  psd.add_argument("--json", action="store_true")
12970
14774
  psd.set_defaults(func=cmd_spec_drift)
14775
+
14776
+ pop = sub.add_parser("operability",
14777
+ help="measure CI / release / RUNBOOK / seed as FACTS in the tree "
14778
+ "(ADR-048); exit 0 always -- the gate is the profile's")
14779
+ add_ledger(pop)
14780
+ pop.add_argument("--repo", required=True)
14781
+ pop.add_argument("--iteration", type=int, default=1)
14782
+ pop.add_argument("--json", action="store_true")
14783
+ pop.set_defaults(func=cmd_operability)
12971
14784
  pre = sub.add_parser("resolve-escalation",
12972
14785
  help="close open escalations for a repo (recorded event; "
12973
14786
  "lifts the readiness cap)")
@@ -12978,24 +14791,82 @@ def build_parser():
12978
14791
 
12979
14792
  plg = sub.add_parser(
12980
14793
  "log-gate",
12981
- help="persist a FACT-gate verdict (golden-diff/gate-check/pit-check/simplicity/regression) "
12982
- "so converged and readiness actually see it")
14794
+ help="persist a FACT-gate verdict (golden-diff/gate-check/pit-check/simplicity/"
14795
+ "regression/ci) so converged and readiness actually see it")
12983
14796
  add_ledger(plg)
12984
14797
  plg.add_argument("--repo", required=True)
12985
14798
  plg.add_argument("--iteration", type=int, required=True)
12986
14799
  plg.add_argument("--kind", required=True,
12987
14800
  choices=["golden-diff", "gate-check", "pit-check", "simplicity",
12988
- "regression", "rubric", "waste"])
14801
+ "regression", "rubric", "waste", "ci", "corpus", "smoke",
14802
+ "operability"],
14803
+ help="ci (2.2.0) records a pipeline run as the FACT it is: a fail caps "
14804
+ "readiness <=65 and blocks convergence exactly like gate-check. "
14805
+ "corpus (ADR-046) is the parity door for a field-truth run measured "
14806
+ "elsewhere; corpus-run writes the same record with the evidence on "
14807
+ "it. smoke (ADR-047) is the same door for a smoke run measured "
14808
+ "elsewhere -- a FACT kind, so advisory is refused on it. "
14809
+ "operability (ADR-048) is accepted for parity with the "
14810
+ "`operability` subcommand, which is what normally writes it")
12989
14811
  plg.add_argument("--verdict", required=True,
12990
14812
  choices=["pass", "fail", "advisory", "not-run"],
12991
14813
  help="advisory (ADR-043) records a measured, non-gating run: it never "
12992
14814
  "caps readiness, never blocks convergence, and never reads as ok; "
12993
- "accepted only for --kind simplicity|waste, a FACT gate refuses it")
14815
+ "accepted only for --kind simplicity|waste|corpus|operability, a FACT "
14816
+ "gate refuses it")
12994
14817
  plg.add_argument("--count", type=int, default=1,
12995
14818
  help="failing finding count (fail only; default 1)")
12996
14819
  plg.add_argument("--note", default=None)
14820
+ plg.add_argument("--ref", default=None,
14821
+ help="where the verdict was measured (a CI run URL or id), stored on the "
14822
+ "record so the evidence outlives the conversation")
12997
14823
  plg.set_defaults(func=cmd_log_gate)
12998
14824
 
14825
+ pcr = sub.add_parser(
14826
+ "corpus-run",
14827
+ help="run a REAL-INPUT corpus against a command and persist gate:corpus (ADR-046): "
14828
+ "field truth for greenfield, where every test payload was invented by the agent")
14829
+ add_ledger(pcr)
14830
+ pcr.add_argument("--repo", required=True)
14831
+ pcr.add_argument("--corpus", default=None,
14832
+ help="JSONL corpus: one {\"input\": ..., \"expected\": ..., \"id\": ...} "
14833
+ "per line (default: repos[R].corpus from the config)")
14834
+ pcr.add_argument("--command", required=True,
14835
+ help="the command under test; each case's input arrives on ITS stdin "
14836
+ "(JSON-encoded when it is not a string)")
14837
+ pcr.add_argument("--threshold", type=float, default=None,
14838
+ help="hit percentage the run must reach to PASS (default: "
14839
+ "repos[R].corpus_threshold, else defaults.corpus_threshold; with "
14840
+ "NONE declared the run is advisory -- a gate needs an adopted budget)")
14841
+ pcr.add_argument("--ac", action="append", default=None,
14842
+ help="criterion id this run is evidence for (repeatable); a criterion "
14843
+ "whose only evidence is a corpus record closes MEASURED iff it passed")
14844
+ pcr.add_argument("--timeout", type=float, default=CORPUS_DEFAULT_TIMEOUT,
14845
+ help="per-case seconds before the case is a miss named `timeout` "
14846
+ "(default %d)" % CORPUS_DEFAULT_TIMEOUT)
14847
+ pcr.add_argument("--max-misses", type=int, default=CORPUS_DEFAULT_MAX_MISSES,
14848
+ help="how many misses to report and persist (default %d)"
14849
+ % CORPUS_DEFAULT_MAX_MISSES)
14850
+ pcr.add_argument("--iteration", type=int, default=1)
14851
+ pcr.add_argument("--json", action="store_true")
14852
+ pcr.set_defaults(func=cmd_corpus_run)
14853
+
14854
+ psi = sub.add_parser(
14855
+ "smoke-ingest",
14856
+ help="ingest a smoke report as gate:smoke (ADR-047): the smoke run MEASURED "
14857
+ "instead of narrated -- evidence is executed, not written down")
14858
+ add_ledger(psi)
14859
+ psi.add_argument("--repo", required=True)
14860
+ psi.add_argument("--report", required=True,
14861
+ help='JSON the PROJECT writes with its own smoke tool: '
14862
+ '{"checks": [{"name": ..., "ok": true|false, "status": ..., '
14863
+ '"latency_ms": ..., "evidence": ...}, ...]}. A missing `checks`, '
14864
+ 'an empty list, or a check without a name or a boolean `ok` is '
14865
+ 'exit 2 naming it -- never a scored run')
14866
+ psi.add_argument("--iteration", type=int, default=1)
14867
+ psi.add_argument("--json", action="store_true")
14868
+ psi.set_defaults(func=cmd_smoke_ingest)
14869
+
12999
14870
  pfb = sub.add_parser(
13000
14871
  "flag-blocker",
13001
14872
  help="record (or --resolve) a CONSTITUTION/invariant breach as a BLOCKER "
@@ -13241,8 +15112,9 @@ def build_parser():
13241
15112
  pgc.add_argument("--ledger", default=DEFAULT_LEDGER,
13242
15113
  help="(with --repo) ledger for the measured test-count cross-check")
13243
15114
  pgc.add_argument("--repo", default=None,
13244
- help="optional: compare the last two snapshots' executed-test totals "
13245
- "(a measured drop flags REVIEW no regex can miss it)")
15115
+ help="SCOPE the diff to repos[R].path (a monorepo sibling's hunks are not "
15116
+ "this repo's findings) and compare the last two snapshots' "
15117
+ "executed-test totals (a measured drop flags REVIEW)")
13246
15118
  pgc.add_argument("--json", action="store_true")
13247
15119
  pgc.set_defaults(func=cmd_gate_check)
13248
15120