@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.
- package/README.md +72 -70
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-adr-refine/SKILL.md +2 -0
- package/uscha-kit/.claude/skills/uscha-characterize/SKILL.md +2 -0
- package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +2 -0
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +285 -31
- package/uscha-kit/.claude/skills/uscha-discovery/SKILL.md +2 -0
- package/uscha-kit/.claude/skills/uscha-mirador/SKILL.md +2 -0
- package/uscha-kit/.claude/skills/uscha-reverse-discovery/SKILL.md +2 -0
- package/uscha-kit/.claude/skills/uscha-rubric/SKILL.md +2 -0
- package/uscha-kit/.claude/skills/uscha-status/SKILL.md +2 -0
- package/uscha-kit/.claude/skills/uscha-sysdoc/SKILL.md +2 -0
- package/uscha-kit/.claude-plugin/plugin.json +1 -1
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +1 -1
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/skills/uscha-adr-refine/SKILL.md +2 -0
- package/uscha-kit/skills/uscha-characterize/SKILL.md +2 -0
- package/uscha-kit/skills/uscha-devloop/SKILL.md +2 -0
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +285 -31
- package/uscha-kit/skills/uscha-discovery/SKILL.md +2 -0
- package/uscha-kit/skills/uscha-mirador/SKILL.md +2 -0
- package/uscha-kit/skills/uscha-reverse-discovery/SKILL.md +2 -0
- package/uscha-kit/skills/uscha-rubric/SKILL.md +2 -0
- package/uscha-kit/skills/uscha-status/SKILL.md +2 -0
- package/uscha-kit/skills/uscha-sysdoc/SKILL.md +2 -0
- package/uscha-kit/templates/CONSTITUTION.md +1 -1
- 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"],
|
|
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"]
|
|
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
|
-
|
|
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(
|
|
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." % (
|
|
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
|
-
|
|
5215
|
-
|
|
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
|
-
|
|
5225
|
-
|
|
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"
|
|
7358
|
+
("subcommands.count", _COUNT + r"sub-?comm?ands", None),
|
|
7259
7359
|
("subcommands.count", r"(\d+)\s+subcomandos", None),
|
|
7260
|
-
|
|
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
|
-
#
|
|
7305
|
-
#
|
|
7306
|
-
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": {
|
package/uscha-kit/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# uscha-kit
|
|
2
2
|
|
|
3
|
-
**Kit version:** v1.
|
|
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.
|
|
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
|
|