@andresmassello/uscha 1.96.0 → 1.98.1

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 (28) hide show
  1. package/README.md +72 -70
  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 +2 -0
  6. package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +285 -31
  7. package/uscha-kit/.claude/skills/uscha-discovery/SKILL.md +2 -0
  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 +2 -0
  12. package/uscha-kit/.claude/skills/uscha-sysdoc/SKILL.md +2 -0
  13. package/uscha-kit/.claude-plugin/plugin.json +1 -1
  14. package/uscha-kit/.codex-plugin/plugin.json +1 -1
  15. package/uscha-kit/README.md +1 -1
  16. package/uscha-kit/VERSION +1 -1
  17. package/uscha-kit/skills/uscha-adr-refine/SKILL.md +2 -0
  18. package/uscha-kit/skills/uscha-characterize/SKILL.md +2 -0
  19. package/uscha-kit/skills/uscha-devloop/SKILL.md +2 -0
  20. package/uscha-kit/skills/uscha-devloop/qa_ledger.py +285 -31
  21. package/uscha-kit/skills/uscha-discovery/SKILL.md +2 -0
  22. package/uscha-kit/skills/uscha-mirador/SKILL.md +2 -0
  23. package/uscha-kit/skills/uscha-reverse-discovery/SKILL.md +2 -0
  24. package/uscha-kit/skills/uscha-rubric/SKILL.md +2 -0
  25. package/uscha-kit/skills/uscha-status/SKILL.md +2 -0
  26. package/uscha-kit/skills/uscha-sysdoc/SKILL.md +2 -0
  27. package/uscha-kit/templates/CONSTITUTION.md +1 -1
  28. package/uscha-kit/uscha.config.json +1 -1
@@ -4140,6 +4140,47 @@ CANDIDATE_DELTA_FILE = "CANDIDATE-DELTA.json" # under discovery/, machine-can
4140
4140
  CANDIDATE_DELTA_TWIN = "CANDIDATE-DELTA.md" # rendered view, regenerated, never a source
4141
4141
  CANONICAL_FILE = "CANONICAL.json" # under discovery/: the promoted package
4142
4142
  ISSUES_DEFERRED_FILE = "ISSUES-DEFERRED.md"
4143
+ # The shape of the work item `promote` writes for a `fix` verdict, and the shape it recognises
4144
+ # again on the next run. The dedupe used to be `o["id"] not in existing` -- a RAW SUBSTRING over
4145
+ # the whole file, so it suppressed the work item for any id merely MENTIONED in prose there and,
4146
+ # because the ids are a hash prefix, for any id that is a prefix of an already-written one
4147
+ # (1.69.0 fresh review, LOW, resolved 1.98.0). The question is not "does this text occur"; it is
4148
+ # "does this file already carry the WORK ITEM for this observation", and only the line shape
4149
+ # answers it. The word boundaries are what separate OBS-1 from OBS-10, and the `.*` between
4150
+ # the checkbox and the id is what lets a HUMAN reword the item -- prefix it with a date, a
4151
+ # severity, an owner -- without `promote` appending a second copy of it on the next run.
4152
+ _DEFERRED_ITEM_LINE = r"(?m)^[ \t]*[-*][ \t]*\[[ xX]\].*\b%s\b"
4153
+
4154
+
4155
+ def _deferred_carries(existing, oid):
4156
+ """True when ISSUES-DEFERRED.md already holds the `- [ ] <oid>` work item for this OBS."""
4157
+ return re.search(_DEFERRED_ITEM_LINE % re.escape(oid), existing) is not None
4158
+
4159
+
4160
+ def _config_beside_ledger(config, ledger_path):
4161
+ """Where to LOOK for a config whose default is a bare relative name.
4162
+
4163
+ `--config` defaulted to the literal `uscha.config.json`, which `os.path.isfile` resolves
4164
+ against the CWD. Run `fidelity --ledger /repo/QA-LEDGER.json` from anywhere but /repo and
4165
+ the config beside the ledger was never opened -- so `defaults.fidelity.gate` was silently
4166
+ not declared, and the INV-ADVISORY-01 refusal that reads it never fired. An UNNAMED absence
4167
+ (1.69.0 fresh review, LOW, resolved 1.98.0): the command printed a full vector and exit 0,
4168
+ exactly as it does when there genuinely is no config.
4169
+
4170
+ The cwd still WINS when it holds the file -- an explicit `--config` next to you is what you
4171
+ meant, and no existing invocation changes behaviour. Only when it does not is the ledger's
4172
+ own directory tried. Returns (path, found): callers report `path` so the answer to "which
4173
+ file did you read" is in the output rather than in the reader's head.
4174
+ """
4175
+ if os.path.isfile(config):
4176
+ return config, True
4177
+ if not os.path.isabs(config):
4178
+ beside = os.path.join(os.path.dirname(os.path.abspath(ledger_path)), config)
4179
+ if os.path.isfile(beside):
4180
+ return beside, True
4181
+ return config, False
4182
+
4183
+
4143
4184
  OBS_TYPES = ("behavior", "invariant", "contract", "config", "dependency", "decision_trace")
4144
4185
  EVIDENCE_CLASSES = ("measured", "static", "narrated")
4145
4186
  # ADR-014 / INV-ADVISORY-01: dimensions an LLM judges can only advise. The QUARANTINE is an
@@ -4445,6 +4486,17 @@ def _match_canonical(statement, canon_ids):
4445
4486
  return None
4446
4487
 
4447
4488
 
4489
+ def _md_cell(text):
4490
+ """One markdown TABLE cell. A `|` is escaped, and any CR/LF inside the value collapses to
4491
+ a space -- a markdown row ENDS at a newline, so a statement or a provenance ref carrying
4492
+ one used to split its observation across two rows and corrupt every column after it
4493
+ (1.69.0 fresh review, LOW, resolved 1.98.0). The JSON and the OBS id always survived; only
4494
+ this rendered view broke, and it is the artifact the human curates from. A space rather
4495
+ than `<br>`: every other cell is plain text, and a lone HTML tag in one column would be
4496
+ the only markup in the table."""
4497
+ return re.sub(r"[\r\n]+", " ", "%s" % text).replace("|", "\\|")
4498
+
4499
+
4448
4500
  def _render_delta_md(delta, verdicts):
4449
4501
  lines = ["<!-- %s -->" % _DELTA_BANNER, "",
4450
4502
  "# CANDIDATE-DELTA (rendered view)", ""]
@@ -4460,9 +4512,10 @@ def _render_delta_md(delta, verdicts):
4460
4512
  for o in delta["observations"]:
4461
4513
  v = verdicts.get(o["id"], "(uncurated)")
4462
4514
  files = ", ".join(o["provenance"].get("files") or []) or "-"
4463
- stmt = o["statement"].replace("|", "\\|")
4464
4515
  lines.append("| %s | %s | %s | %s | %s | %s |"
4465
- % (o["id"], o["type"], o["evidence_class"], v, stmt, files))
4516
+ % tuple(_md_cell(c) for c in (o["id"], o["type"],
4517
+ o["evidence_class"], v,
4518
+ o["statement"], files)))
4466
4519
  lines.append("")
4467
4520
  return "\n".join(lines)
4468
4521
 
@@ -4771,15 +4824,18 @@ def cmd_promote(args):
4771
4824
  if os.path.isfile(dpath):
4772
4825
  with open(dpath, encoding="utf-8-sig", errors="replace") as fh:
4773
4826
  existing = fh.read()
4774
- add = [o for o in fixes if o["id"] not in existing]
4827
+ add = [o for o in fixes if not _deferred_carries(existing, o["id"])]
4775
4828
  if add:
4776
4829
  with open(dpath, "a", encoding="utf-8", newline="\n") as fh:
4777
4830
  if existing and not existing.endswith("\n"):
4778
4831
  fh.write("\n")
4779
4832
  for o in add:
4833
+ # a markdown checklist item ends at a newline just as a table row
4834
+ # does: a multi-line statement would split the work item in two and
4835
+ # leave the half _deferred_carries recognises without its text.
4780
4836
  fh.write("- [ ] %s (curated `fix`): %s -- observed behavior the human "
4781
4837
  "ruled a defect; NEVER canonical (ADR-013)\n"
4782
- % (o["id"], o["statement"]))
4838
+ % (o["id"], _md_cell(o["statement"])))
4783
4839
  new_fix = [o["id"] for o in add]
4784
4840
  ledger["candidate_delta"] = {"repo": args.repo, "total": len(obs),
4785
4841
  "curated": len(obs),
@@ -4817,16 +4873,20 @@ def cmd_fidelity(args):
4817
4873
  node = _repo_node(ledger, args.repo)
4818
4874
  repo_path = _scope_path(ledger, args.repo)
4819
4875
  cfg = {}
4820
- if os.path.isfile(args.config):
4876
+ # WHERE the config was looked for is part of the answer: a gate declared in a file the
4877
+ # command never opened is a gate that does not exist, and the old cwd-only resolution said
4878
+ # nothing about it (1.69.0 fresh review, resolved 1.98.0).
4879
+ cfg_path, cfg_found = _config_beside_ledger(args.config, args.ledger)
4880
+ if cfg_found:
4821
4881
  try:
4822
- with open(args.config, encoding="utf-8-sig") as fh:
4882
+ with open(cfg_path, encoding="utf-8-sig") as fh:
4823
4883
  cfg = json.load(fh)
4824
4884
  except (OSError, ValueError) as exc:
4825
4885
  # a config that cannot be parsed cannot declare gates -- swallowing the error
4826
4886
  # would DISABLE the INV-ADVISORY-01 refusal on a syntax slip (fresh-review
4827
4887
  # HIGH, reproduced). Malformation is exit 2, never a silent degrade.
4828
4888
  print("[qa_ledger] fidelity: %s unreadable: %s -- refusing to guess what it "
4829
- "declares." % (args.config, exc), file=sys.stderr)
4889
+ "declares." % (cfg_path, exc), file=sys.stderr)
4830
4890
  sys.exit(2)
4831
4891
  declared = ((cfg.get("defaults") or {}).get("fidelity") or {}).get("gate") or []
4832
4892
  for dim in (ledger.get("config", {}).get("defaults", {}).get("fidelity")
@@ -4979,6 +5039,7 @@ def cmd_fidelity(args):
4979
5039
  dims["semantic"] = _fid_dim(None, "not wired: an LLM-judged comparison enters as "
4980
5040
  "advisory only and can NEVER gate (INV-ADVISORY-01)")
4981
5041
  out = {"repo": args.repo,
5042
+ "config": cfg_path if cfg_found else None,
4982
5043
  **({"path": bound} if bound else {}),
4983
5044
  "dimensions": {k: dict(dims[k], **{"class": FIDELITY_DIMENSIONS[k]})
4984
5045
  for k in ("traceability", "behavior", "contracts",
@@ -4990,6 +5051,11 @@ def cmd_fidelity(args):
4990
5051
  else:
4991
5052
  print("FIDELITY %s%s (vector -- no blend; each number stands on its own evidence):"
4992
5053
  % (args.repo, " [bounded to %s]" % bound if bound else ""))
5054
+ if cfg_found:
5055
+ print(" config %s" % cfg_path)
5056
+ else:
5057
+ print(" config none (looked for %r in the cwd and beside the ledger) "
5058
+ "-- no fidelity gate declared from a file" % args.config)
4993
5059
  for k, d in out["dimensions"].items():
4994
5060
  val = "UNMEASURED" if d["value"] is None else "%.2f" % d["value"]
4995
5061
  print(" %-17s %-10s [%s] %s" % (k, val, d["class"], d["provenance"]))
@@ -5211,8 +5277,8 @@ def _render_ir_md(graph):
5211
5277
  "|----|------|-----------|--------|"]
5212
5278
  for nd in graph.get("nodes") or []:
5213
5279
  src = "%s:%s" % (nd["source"]["file"], nd["source"]["line"])
5214
- stmt = (nd.get("statement") or "").replace("|", "\\|")
5215
- lines.append("| %s | %s | %s | %s |" % (nd["id"], nd["type"], stmt, src))
5280
+ lines.append("| %s | %s | %s | %s |"
5281
+ % (nd["id"], nd["type"], _md_cell(nd.get("statement") or ""), src))
5216
5282
  lines += ["", "## Edges", "", "| from | type | to |", "|------|------|----|"]
5217
5283
  for e in graph.get("edges") or []:
5218
5284
  lines.append("| %s | %s | %s |" % (e["from"], e["type"], e["to"]))
@@ -5221,8 +5287,11 @@ def _render_ir_md(graph):
5221
5287
  "| text | source | reason |", "|------|--------|--------|"]
5222
5288
  for u in graph["untyped"]:
5223
5289
  src = "%s:%s" % (u["source"]["file"], u["source"]["line"])
5224
- txt = (u.get("text") or "").replace("|", "\\|")[:80]
5225
- lines.append("| %s | %s | %s |" % (txt, src, u.get("reason", "")))
5290
+ # truncate AFTER the cell is flattened: slicing raw text can cut mid-newline
5291
+ # and leave the break inside the 80 characters that reach the row.
5292
+ txt = _md_cell(u.get("text") or "")[:80]
5293
+ lines.append("| %s | %s | %s |"
5294
+ % (txt, src, _md_cell(u.get("reason", ""))))
5226
5295
  lines.append("")
5227
5296
  return "\n".join(lines)
5228
5297
 
@@ -7251,13 +7320,46 @@ def _derive_facts():
7251
7320
  }
7252
7321
 
7253
7322
 
7323
+ # Spelled-out counts are claims too, and the paper writes both forms in one sentence -- "nine
7324
+ # agent skills and a dependency-free Python engine with 53 subcommands". A gate that only sees
7325
+ # digits reads half of that sentence and calls the file green. 1..99 covers every count this repo
7326
+ # derives, with room; above it the number is written in digits everywhere it appears.
7327
+ _ONES = ("zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
7328
+ "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen",
7329
+ "seventeen", "eighteen", "nineteen")
7330
+ _TENS = ("", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety")
7331
+
7332
+
7333
+ def _spell(n):
7334
+ """1..99 in English words, hyphenated ("fifty-three"); None outside that range."""
7335
+ if not 1 <= n <= 99:
7336
+ return None
7337
+ if n < 20:
7338
+ return _ONES[n]
7339
+ return _TENS[n // 10] + ("-" + _ONES[n % 10] if n % 10 else "")
7340
+
7341
+
7342
+ _SPELLED = dict((_spell(n), n) for n in range(1, 100))
7343
+ # longest alternative first: an alternation offering "six" before "sixty-three" matches the prefix
7344
+ _NUM_ALT = "|".join(sorted(_SPELLED, key=len, reverse=True))
7345
+ # the leading \b so that "someone skills" cannot be read as the claim "one skills"
7346
+ _COUNT = r"\b(\d+|" + _NUM_ALT + r")\s+"
7347
+
7348
+
7349
+ def _unspell(token):
7350
+ """The integer a spelled-out count names, or None when the token is not one."""
7351
+ return _SPELLED.get(token.lower())
7352
+
7353
+
7254
7354
  _CLAIM_PATTERNS = (
7255
7355
  # (fact key path, regex over one line, needs-context substring or None)
7256
7356
  ("version", r"v(\d+\.\d+\.\d+)", "kit"),
7257
7357
  ("version", r"uscha-kit\s+v?(\d+\.\d+\.\d+)", None),
7258
- ("subcommands.count", r"(\d+)\s+sub-?comm?ands", None),
7358
+ ("subcommands.count", _COUNT + r"sub-?comm?ands", None),
7259
7359
  ("subcommands.count", r"(\d+)\s+subcomandos", None),
7260
- ("skills.count", r"(\d+)\s+skills", None),
7360
+ # "agent skills" is the kit's own noun phrase and the paper's; nothing wider is let in,
7361
+ # because a WRITER that guessed at "two other skills" would corrupt the sentence it fixed.
7362
+ ("skills.count", _COUNT + r"(?:agent\s+)?skills", None),
7261
7363
  )
7262
7364
 
7263
7365
 
@@ -7268,6 +7370,110 @@ def _fact_value(facts, dotted):
7268
7370
  return str(cur)
7269
7371
 
7270
7372
 
7373
+ def _claim_norm(token):
7374
+ """The comparable form of a claimed token: a spelled-out count normalises to its digits, so
7375
+ "nine skills" and "9 skills" are one claim compared against one fact."""
7376
+ n = _unspell(token)
7377
+ return token if n is None else str(n)
7378
+
7379
+
7380
+ def _claim_rewrite(token, actual):
7381
+ """The replacement for one claimed token, in the AUTHOR's notation: a spelled-out claim is
7382
+ rewritten spelled out and keeps its leading capital, a numeric one numerically. Rewriting
7383
+ "nine skills" as "10 skills" would fix the fact and break the sentence."""
7384
+ if _unspell(token) is None:
7385
+ return actual
7386
+ word = _spell(int(actual)) if actual.isdigit() else None
7387
+ if word is None:
7388
+ return actual
7389
+ return word[0].upper() + word[1:] if token[:1].isupper() else word
7390
+
7391
+
7392
+ def _iter_claims(line):
7393
+ """Every recognised claim on ONE line, as (fact key, start, end, token).
7394
+
7395
+ ONE recogniser, two consumers: `--check` reports what this yields and `--write` rewrites
7396
+ exactly what this yields. A writer that re-implemented the patterns could disagree with the
7397
+ checker, and the disagreement would surface as a release that refuses after fixing itself.
7398
+
7399
+ HTML comment spans are SKIPPED rather than deleted -- a comment is not a published claim
7400
+ (the first live run flagged a section marker reading "2 Skills"), and `--write` needs offsets
7401
+ into the ORIGINAL line. Overlapping matches collapse: the two version patterns can name the
7402
+ same digits, and rewriting one span twice would corrupt the line."""
7403
+ spans = [m.span() for m in re.finditer(r"<!--.*?-->", line)]
7404
+ low = line.lower()
7405
+ found = []
7406
+ for key, pat, ctx in _CLAIM_PATTERNS:
7407
+ if ctx and ctx not in low:
7408
+ continue
7409
+ for m in re.finditer(pat, line, re.I):
7410
+ if any(a <= m.start() < b for a, b in spans):
7411
+ continue
7412
+ found.append((m.start(1), m.end(1), key, m.group(1)))
7413
+ out, taken = [], []
7414
+ for start, end, key, token in sorted(found):
7415
+ if any(start < e and s < end for s, e in taken):
7416
+ continue
7417
+ taken.append((start, end))
7418
+ out.append((key, start, end, token))
7419
+ return out
7420
+
7421
+
7422
+ def _write_claims(facts, paths):
7423
+ """Rewrite every recognised STALE claim in `paths` to the derived fact, byte for byte
7424
+ otherwise. Line endings are preserved PER FILE (newline="" on both ends): the gated set mixes
7425
+ LF sources with CRLF-checked-out HTML, and normalising them would turn a one-token fix into a
7426
+ whole-file diff nobody can review.
7427
+
7428
+ What it deliberately does NOT touch: anything the patterns do not recognise. A missing
7429
+ subcommand table row, a count spelled outside 1..99, a claim phrased in prose -- those stay
7430
+ for the human, and the --check that follows still fails on them."""
7431
+ changed, total, problems = 0, 0, 0
7432
+ for path in paths:
7433
+ try:
7434
+ with open(path, encoding="utf-8", newline="") as fh:
7435
+ body = fh.read()
7436
+ except OSError as exc:
7437
+ print(" !! %s: unreadable: %s" % (path, exc))
7438
+ problems += 1
7439
+ continue
7440
+ except UnicodeDecodeError as exc:
7441
+ # `--check` reads this file with errors="replace" and still reports its claims, so
7442
+ # nothing is hidden. `--write` must NOT read it that way: writing a replaced byte
7443
+ # back would destroy data to fix a version number, and a writer that corrupts a file
7444
+ # to correct a claim is worse than the claim. Named, skipped, and the --check that
7445
+ # follows still fails on whatever is stale in it.
7446
+ print(" !! %s: not valid UTF-8 (%s) -- left untouched; --check still reads it"
7447
+ % (path, exc))
7448
+ problems += 1
7449
+ continue
7450
+ lines = body.split("\n")
7451
+ n = 0
7452
+ for i, line in enumerate(lines):
7453
+ claims = _iter_claims(line)
7454
+ if not claims:
7455
+ continue
7456
+ # right to left: an earlier rewrite must not move the offsets of a later one
7457
+ for key, start, end, token in sorted(claims, key=lambda c: c[1], reverse=True):
7458
+ actual = _fact_value(facts, key)
7459
+ if _claim_norm(token) == actual:
7460
+ continue
7461
+ line = line[:start] + _claim_rewrite(token, actual) + line[end:]
7462
+ n += 1
7463
+ lines[i] = line
7464
+ if not n:
7465
+ continue
7466
+ with open(path, "w", encoding="utf-8", newline="") as fh:
7467
+ fh.write("\n".join(lines))
7468
+ print("%s: %d claim(s) rewritten" % (path, n))
7469
+ changed += 1
7470
+ total += n
7471
+ print("FACTS --write: %d claim(s) rewritten in %d of %d file(s)%s"
7472
+ % (total, changed, len(paths),
7473
+ "" if not problems else "; %d file(s) could not be read" % problems))
7474
+ return problems
7475
+
7476
+
7271
7477
  def cmd_facts(args):
7272
7478
  """Generate SYSTEM-FACTS.json, or --check published claims against the derived facts.
7273
7479
 
@@ -7276,6 +7482,24 @@ def cmd_facts(args):
7276
7482
  about factual drift. A claim that CI does not compare against a derived fact will
7277
7483
  drift; this makes the comparison mechanical and the drift a named red."""
7278
7484
  facts = _derive_facts()
7485
+ if args.write is not None:
7486
+ # Until 1.97.0 there was no writer, so every bump was ~25 hand edits across ~13 files and
7487
+ # the release script could only refuse and hand them back. `--write` rewrites the claims
7488
+ # the SAME recogniser finds, then runs the SAME --check: a writer that reported its own
7489
+ # success would be exactly the self-graded evidence this engine exists to refuse.
7490
+ if args.check is not None:
7491
+ # Before 1.97.0 this combination silently dropped --check's files: --write set
7492
+ # args.check to ITS list. Two file sets given, one measured, no word about it.
7493
+ print("[qa_ledger] facts: --check and --write are alternatives, not a pair -- "
7494
+ "--write already re-checks exactly the files it wrote.", file=sys.stderr)
7495
+ sys.exit(2)
7496
+ if not args.write:
7497
+ print("[qa_ledger] facts: --write needs at least one file.", file=sys.stderr)
7498
+ sys.exit(2)
7499
+ if _write_claims(facts, args.write):
7500
+ # an unreadable file is an UNMEASURED claim set, and unmeasured is not green
7501
+ sys.exit(2)
7502
+ args.check = list(args.write)
7279
7503
  if args.check:
7280
7504
  problems = []
7281
7505
  # 1) the committed facts file must match a fresh derivation (stale facts are drift)
@@ -7301,21 +7525,17 @@ def cmd_facts(args):
7301
7525
  table_names = []
7302
7526
  table_start = 0
7303
7527
  for n, line in enumerate(lines, 1):
7304
- # an HTML comment is not a published claim -- the first live run flagged a
7305
- # section marker (a comment reading "2 Skills") as a drifted count
7306
- line = re.sub(r"<!--.*?-->", "", line)
7307
- low = line.lower()
7308
- for key, pat, ctx in _CLAIM_PATTERNS:
7309
- if ctx and ctx not in low:
7310
- continue
7311
- for m in re.finditer(pat, line, re.I):
7312
- claimed = m.group(1)
7313
- actual = _fact_value(facts, key)
7314
- if claimed != actual:
7315
- problems.append((path, n, key, claimed, actual))
7528
+ # the claims come from the SHARED recogniser (`_iter_claims`), which skips HTML
7529
+ # comment spans -- a comment is not a published claim
7530
+ for key, _s, _e, claimed in _iter_claims(line):
7531
+ actual = _fact_value(facts, key)
7532
+ if _claim_norm(claimed) != actual:
7533
+ problems.append((path, n, key, claimed, actual))
7316
7534
  # the parser-surface table (Subcommand/Subcomando header, one `<td class="t">`
7317
7535
  # row per subcommand) is a claim too, just not a numeric one -- a row can go
7318
7536
  # missing while the count beside it stays correct (the `top` row did, once).
7537
+ # This half reads the comment-STRIPPED line: a commented-out row is not a row.
7538
+ line = re.sub(r"<!--.*?-->", "", line)
7319
7539
  if not in_table:
7320
7540
  if re.search(r"<th>Sub ?comm?ando?s?</th>", line, re.I):
7321
7541
  in_table, table_names, table_start = True, [], n
@@ -11038,18 +11258,33 @@ def _lc_valid_date(s):
11038
11258
  return False
11039
11259
  _LC_KEY = re.compile(r"^lifecycle\s*:\s*(.*)$")
11040
11260
  _LC_GOLIVE_FM = re.compile(r"^go[-_ ]?live\s*:\s*(.+?)\s*$", re.I)
11041
- _LC_GOLIVE_LINE = re.compile(r"^\s*\*\*\s*go[-_ ]?live\s*:?\s*\*\*\s*:?\s*"
11261
+ # the body form. A list marker or a blockquote prefix in front of the bold label is still the
11262
+ # same declaration -- a SPEC that writes it as a bullet used to read as "no go-live declared"
11263
+ # (1.98.1, field report). The prefix is markdown-CORRECT, not merely permissive: at most three
11264
+ # leading spaces and never a tab, because four spaces or a tab opens an INDENTED CODE BLOCK --
11265
+ # an example, not a declaration -- and a list marker must be FOLLOWED by whitespace, so the
11266
+ # glued `-**Go-live:**` is not a list item. Fenced blocks are skipped by the caller for the
11267
+ # same reason. The trailing \b keeps the date a WHOLE token: it must be followed by a
11268
+ # non-digit or the end of the line, so a longer number can never be truncated into a date,
11269
+ # while ordinary prose after it ("(delivery 1). ...") is allowed.
11270
+ _LC_GOLIVE_LINE = re.compile(r"^ {0,3}(?:> ?)*(?:(?:[-*+]|\d+[.)])\s+)?"
11271
+ r"\*\*\s*go[-_ ]?live\s*:?\s*\*\*\s*:?\s*"
11042
11272
  r"(\d{4}-\d{2}-\d{2})\b", re.I)
11043
11273
  _LC_FIELDS = ("component", "version", "eol", "source", "checked")
11044
11274
 
11045
11275
 
11046
11276
  def _lc_frontmatter(lines):
11047
11277
  """The lines INSIDE a leading `---` frontmatter block, or None when there is none.
11278
+ Leading blank lines and a BOM are skipped before the fence is looked for: a file whose
11279
+ text reached us with anything glued in front still opens its block at the top (1.98.1).
11048
11280
  An unterminated fence is not data: a half-written block reads as absent."""
11049
- if not lines or lines[0].strip() != "---":
11281
+ i = 0
11282
+ while i < len(lines) and not lines[i].lstrip("\ufeff").strip():
11283
+ i += 1
11284
+ if i >= len(lines) or lines[i].lstrip("\ufeff").strip() != "---":
11050
11285
  return None
11051
11286
  body = []
11052
- for ln in lines[1:]:
11287
+ for ln in lines[i + 1:]:
11053
11288
  if ln.strip() == "---":
11054
11289
  return body
11055
11290
  body.append(ln)
@@ -11101,7 +11336,8 @@ def _lc_parse(lines):
11101
11336
 
11102
11337
  def _lc_go_live(text):
11103
11338
  """The declared go-live of a SPEC: frontmatter `go_live: YYYY-MM-DD`, or a
11104
- `**Go-live:** YYYY-MM-DD` line anywhere in the body. None = not declared."""
11339
+ `**Go-live:** YYYY-MM-DD` line anywhere in the body OUTSIDE a fenced block.
11340
+ None = not declared."""
11105
11341
  lines = [ln.rstrip("\r") for ln in (text or "").split("\n")]
11106
11342
  fm = _lc_frontmatter(lines)
11107
11343
  if fm:
@@ -11113,7 +11349,14 @@ def _lc_go_live(text):
11113
11349
  v = m.group(1).strip().strip("\x27\x22")
11114
11350
  if _lc_valid_date(v):
11115
11351
  return v
11352
+ in_fence = False
11116
11353
  for ln in lines:
11354
+ st = ln.strip()
11355
+ if st.startswith("```") or st.startswith("~~~"):
11356
+ in_fence = not in_fence
11357
+ continue
11358
+ if in_fence: # a SPEC that QUOTES the form is showing it, not declaring a date --
11359
+ continue # and the quoted line comes FIRST (the fence idiom of _spec_check_text)
11117
11360
  m = _LC_GOLIVE_LINE.match(ln)
11118
11361
  if m and _lc_valid_date(m.group(1)):
11119
11362
  return m.group(1)
@@ -11225,7 +11468,12 @@ def _lifecycle_for(root, adr_dir=None, spec_text=None, fallback=True):
11225
11468
  if os.path.isfile(sp):
11226
11469
  try:
11227
11470
  with open(sp, "r", encoding="utf-8", errors="replace") as fh:
11228
- spec_text = (spec_text or "") + "\n" + fh.read()
11471
+ body = fh.read()
11472
+ # join only when there is something to join TO. Gluing a newline in front of
11473
+ # the file pushed its `---` off line 0, so the frontmatter went invisible and
11474
+ # `readiness` read "no go-live declared" on a SPEC that `spec-check` measured
11475
+ # (1.98.1, field report).
11476
+ spec_text = (spec_text + "\n" + body) if spec_text else body
11229
11477
  except OSError:
11230
11478
  pass
11231
11479
  return _lifecycle_report(adr_dir or os.path.join(root, "docs", "adr"), spec_text)
@@ -12288,6 +12536,10 @@ def build_parser():
12288
12536
  pfa.add_argument("--out", default="SYSTEM-FACTS.json")
12289
12537
  pfa.add_argument("--check", nargs="*", default=None,
12290
12538
  help="files whose claims must match the derived facts; exit 1 on drift")
12539
+ pfa.add_argument("--write", nargs="*", default=None,
12540
+ help="files whose recognised claims are REWRITTEN to the derived facts "
12541
+ "(spelled-out claims stay spelled out), then re-checked: exit 1 if "
12542
+ "anything still disagrees")
12291
12543
  pfa.set_defaults(func=cmd_facts)
12292
12544
 
12293
12545
  prt = sub.add_parser("roundtrip",
@@ -12345,7 +12597,9 @@ def build_parser():
12345
12597
  pfv.add_argument("--ledger", default="QA-LEDGER.json")
12346
12598
  pfv.add_argument("--repo", required=True)
12347
12599
  pfv.add_argument("--config", default="uscha.config.json",
12348
- help="checked for defaults.fidelity.gate -- advisory there is a refusal")
12600
+ help="checked for defaults.fidelity.gate -- advisory there is a "
12601
+ "refusal. A relative name is resolved against the cwd first, "
12602
+ "then beside --ledger; the path actually read is reported")
12349
12603
  pfv.add_argument("--ir", action="store_true",
12350
12604
  help="answer curation_closure as a path query over the IR graph "
12351
12605
  "(ADR-015); reproduces v0 from the derived index")
@@ -18,6 +18,7 @@ The human brings the idea, the constraints and the reference material. **You bri
18
18
  shape.** Your job is to interrogate until there is a shared system shape, and to write
19
19
  the documents as you go — not to ask the human to design the system for you.
20
20
 
21
+ <!-- uscha:orientation-block:begin -->
21
22
  ## First contact (show ONCE, then never again)
22
23
 
23
24
  **Only when this project has no uscha artifacts yet** -- no `QA-LEDGER.json`, no `SPEC.md` or
@@ -85,6 +86,7 @@ and say exactly what unblocks it.
85
86
 
86
87
  Keep the CONTENT in the conversation's language, but keep the labels (`CLOSED`, `Produced`,
87
88
  `Blocks`, `Next`, `Run`) verbatim — they are the method's vocabulary and the smoke checks them.
89
+ <!-- uscha:orientation-block:end -->
88
90
 
89
91
  ## Non-negotiable principles
90
92
 
@@ -17,6 +17,7 @@ allowed-tools: Read, Write, Glob, Grep, Bash
17
17
  Paints the REAL state of the project at a glance. It does not narrate or estimate: it
18
18
  wires the JSON the engine emits into the template. Read-only.
19
19
 
20
+ <!-- uscha:orientation-block:begin -->
20
21
  ## Orientation markers (non-negotiable)
21
22
 
22
23
  The operator must never have to ask "where am I?" or "what happens now?".
@@ -40,6 +41,7 @@ Run: <the exact command or skill to invoke>
40
41
  including any `Flow:` line in this file. If nothing is actionable, say that plainly rather
41
42
  than inventing a step. Keep the CONTENT in the conversation's language and the labels
42
43
  (`Next`, `Run`) verbatim — the smoke suite checks for them.
44
+ <!-- uscha:orientation-block:end -->
43
45
 
44
46
  ## Contract
45
47
 
@@ -23,6 +23,7 @@ first, always — and what cannot be fact yet becomes an OBSERVATION in quaranti
23
23
  evidence-classed, content-addressed, and promoted to the contract only by a per-observation
24
24
  human verdict (ADR-013).**
25
25
 
26
+ <!-- uscha:orientation-block:begin -->
26
27
  ## First contact (show ONCE, then never again)
27
28
 
28
29
  **Only when this project has no uscha artifacts yet** -- no `QA-LEDGER.json`, no `SPEC.md` or
@@ -92,6 +93,7 @@ and say exactly what unblocks it.
92
93
 
93
94
  Keep the CONTENT in the conversation's language, but keep the labels (`CLOSED`, `Produced`,
94
95
  `Blocks`, `Next`, `Run`) verbatim — they are the method's vocabulary and the smoke checks them.
96
+ <!-- uscha:orientation-block:end -->
95
97
 
96
98
  ## The one non-negotiable: quarantine, not judgment (ADR-009)
97
99
 
@@ -21,6 +21,7 @@ contract + `qa_ledger.py rubric-ingest` (stdlib, runs anywhere). ANY runner can
21
21
  the grader — this skill just wraps the neutral prompt so Claude Code users get it
22
22
  in one command. Never add Claude-specific behavior to the contract.
23
23
 
24
+ <!-- uscha:orientation-block:begin -->
24
25
  ## First contact (show ONCE, then never again)
25
26
 
26
27
  **Only when this project has no uscha artifacts yet** -- no `QA-LEDGER.json`, no `SPEC.md` or
@@ -88,6 +89,7 @@ and say exactly what unblocks it.
88
89
 
89
90
  Keep the CONTENT in the conversation's language, but keep the labels (`CLOSED`, `Produced`,
90
91
  `Blocks`, `Next`, `Run`) verbatim — they are the method's vocabulary and the smoke checks them.
92
+ <!-- uscha:orientation-block:end -->
91
93
 
92
94
  ## Protocol
93
95
 
@@ -19,6 +19,7 @@ skill (**pull** — one screen when the human asks), and the **mirador** (bird's
19
19
  HTML). This skill exists because some surfaces never show a statusline; the answer
20
20
  is the same data, printed in chat when requested.
21
21
 
22
+ <!-- uscha:orientation-block:begin -->
22
23
  ## Orientation markers (non-negotiable)
23
24
 
24
25
  The operator must never have to ask "where am I?" or "what happens now?".
@@ -42,6 +43,7 @@ Run: <the exact command or skill to invoke>
42
43
  including any `Flow:` line in this file. If nothing is actionable, say that plainly rather
43
44
  than inventing a step. Keep the CONTENT in the conversation's language and the labels
44
45
  (`Next`, `Run`) verbatim — the smoke suite checks for them.
46
+ <!-- uscha:orientation-block:end -->
45
47
 
46
48
  ## Contract
47
49
 
@@ -21,6 +21,7 @@ switch between at any time:
21
21
  - **Technical track** — architecture, modules, data flow, contracts, QA results,
22
22
  coverage, known deferred issues.
23
23
 
24
+ <!-- uscha:orientation-block:begin -->
24
25
  ## First contact (show ONCE, then never again)
25
26
 
26
27
  **Only when this project has no uscha artifacts yet** -- no `QA-LEDGER.json`, no `SPEC.md` or
@@ -88,6 +89,7 @@ and say exactly what unblocks it.
88
89
 
89
90
  Keep the CONTENT in the conversation's language, but keep the labels (`CLOSED`, `Produced`,
90
91
  `Blocks`, `Next`, `Run`) verbatim — they are the method's vocabulary and the smoke checks them.
92
+ <!-- uscha:orientation-block:end -->
91
93
 
92
94
  ## Inputs
93
95
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "uscha",
4
- "version": "1.96.0",
4
+ "version": "1.98.1",
5
5
  "displayName": "Uscha",
6
6
  "description": "Spec-driven development for LLM coding agents: 9 skills (discovery, adr-refine, reverse-discovery, characterize, devloop, sysdoc, rubric, mirador, status) + a stdlib measurement engine (qa_ledger.py, 53 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
7
7
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uscha",
3
- "version": "1.96.0",
3
+ "version": "1.98.1",
4
4
  "description": "Uscha spec-driven development methodology for coding agents. Includes npm/npx router.",
5
5
  "author": {
6
6
  "name": "Andres Massello",
@@ -1,6 +1,6 @@
1
1
  # uscha-kit
2
2
 
3
- **Kit version:** v1.96.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
3
+ **Kit version:** v1.98.1 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
4
4
 
5
5
  Spec-driven orchestrator + multi-repo QA for Claude Code, with a deterministic ledger.
6
6
  **Nine skills** (`uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`,
package/uscha-kit/VERSION CHANGED
@@ -1 +1 @@
1
- uscha-kit 1.96.0
1
+ uscha-kit 1.98.1
@@ -18,6 +18,7 @@ You convert a rough idea into a development-ready specification. You do this in
18
18
  phases. **You are NOT a generator. You are an interrogator that distills.** The value
19
19
  is in the questions, not in agreeing.
20
20
 
21
+ <!-- uscha:orientation-block:begin -->
21
22
  ## First contact (show ONCE, then never again)
22
23
 
23
24
  **Only when this project has no uscha artifacts yet** -- no `QA-LEDGER.json`, no `SPEC.md` or
@@ -85,6 +86,7 @@ and say exactly what unblocks it.
85
86
 
86
87
  Keep the CONTENT in the conversation's language, but keep the labels (`CLOSED`, `Produced`,
87
88
  `Blocks`, `Next`, `Run`) verbatim — they are the method's vocabulary and the smoke checks them.
89
+ <!-- uscha:orientation-block:end -->
88
90
 
89
91
  ## Non-negotiable principles
90
92
 
@@ -18,6 +18,7 @@ return, you encode the same partial understanding that loses logic silently. **Y
18
18
  what the code DOES, mechanically, by running it — never what it should do.** You may write
19
19
  the capture harness; you may NOT create, rename, or edit any `.approved` file.
20
20
 
21
+ <!-- uscha:orientation-block:begin -->
21
22
  ## First contact (show ONCE, then never again)
22
23
 
23
24
  **Only when this project has no uscha artifacts yet** -- no `QA-LEDGER.json`, no `SPEC.md` or
@@ -85,6 +86,7 @@ and say exactly what unblocks it.
85
86
 
86
87
  Keep the CONTENT in the conversation's language, but keep the labels (`CLOSED`, `Produced`,
87
88
  `Blocks`, `Next`, `Run`) verbatim — they are the method's vocabulary and the smoke checks them.
89
+ <!-- uscha:orientation-block:end -->
88
90
 
89
91
  ## Inputs
90
92