@andresmassello/uscha 1.93.1 → 1.94.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 +2 -2
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-adr-refine/SKILL.md +30 -6
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +297 -38
- package/uscha-kit/.claude/skills/uscha-discovery/SKILL.md +31 -7
- 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 +30 -6
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +297 -38
- package/uscha-kit/skills/uscha-discovery/SKILL.md +31 -7
- package/uscha-kit/templates/CONSTITUTION.md +17 -0
- package/uscha-kit/templates/docs/adr/ADR-stack-template.md +73 -0
- package/uscha-kit/templates/docs/adr/README.md +5 -0
- package/uscha-kit/uscha.config.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Requires **Python 3.8+** on the machine (the engine is Python stdlib — no pip
|
|
|
40
40
|
runtime dependencies). The npm package is a thin router; the canonical installer is
|
|
41
41
|
`uscha-kit/install-uscha.py`.
|
|
42
42
|
|
|
43
|
-
**Kit v1.
|
|
43
|
+
**Kit v1.94.1** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
|
|
44
44
|
[changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG.md)
|
|
45
45
|
(the per-release changelogs live in the repo, not in the npm tarball)
|
|
46
46
|
|
|
@@ -85,7 +85,7 @@ automatic tool can perform: a human verdict.
|
|
|
85
85
|
from the compiled code: 0.828 measured (12 archetypes) — names AND behaviour
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
**What each arrow is, in the engine (kit 1.
|
|
88
|
+
**What each arrow is, in the engine (kit 1.94.1, 53 subcommands, all measured):**
|
|
89
89
|
|
|
90
90
|
| Leg | Subcommands | What it establishes |
|
|
91
91
|
|---|---|---|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andresmassello/uscha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.94.1",
|
|
4
4
|
"description": "Spec-driven development for LLM coding agents: 9 skills + a stdlib evidence engine. Facts block, guesses advise; the human approves.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Andres Massello",
|
|
@@ -112,21 +112,45 @@ it's already fully answered. Keep a running list of OPEN GAPS and resolved decis
|
|
|
112
112
|
2. **Implicit decisions.** Surface the choices the request assumed: sync vs async,
|
|
113
113
|
storage, protocol, idempotency, transactional boundaries, who owns state. For each,
|
|
114
114
|
force an explicit decision and at least one considered alternative.
|
|
115
|
-
3. **
|
|
115
|
+
3. **Stack and lifecycle (MANDATORY — before any stack/architecture decision is
|
|
116
|
+
recorded).** The stack is not a given ("we use X"): it is a decision with an EXPIRY
|
|
117
|
+
DATE. A major version is a family; support is granted to a MINOR line, for a window,
|
|
118
|
+
by an upstream nobody in the room controls. Ask these one at a time, each with your
|
|
119
|
+
recommended answer, and **FETCH the dates from the official source AS YOU ASK — never
|
|
120
|
+
answer from memory**; record the URL and the day you checked:
|
|
121
|
+
(a) the EXACT version of every runtime/framework/store (JDK, web framework, ORM, DB,
|
|
122
|
+
Node, bundler, broker, cache) and its OSS/LTS end-of-support date, cited;
|
|
123
|
+
(b) support window vs the declared go-live AND the expected operating life — does the
|
|
124
|
+
line stay patched for the whole operation? If not, move it up BEFORE building: a major
|
|
125
|
+
upgrade days before the milestone is the most expensive one there is;
|
|
126
|
+
(c) major dependencies and the upgrade policy — who approves one, and when it is
|
|
127
|
+
scheduled (aligned with the dev-loop's "zero new dependencies without approval");
|
|
128
|
+
(d) development and observability tools the operator wants from day one (consoles, APM,
|
|
129
|
+
admin UIs) — they CONSTRAIN versions, so ask early or they force the upgrade;
|
|
130
|
+
(e) compatibility with reused legacy modules — the minimum version they support.
|
|
131
|
+
The answers distil into the stack ADR with the dates INSIDE it, as the machine-readable
|
|
132
|
+
`lifecycle:` frontmatter block (`component` / `version` / `eol` / `source` / `checked`,
|
|
133
|
+
ISO dates; `eol: unknown` is allowed and reads as a NAMED absence, never a pass) — see
|
|
134
|
+
`templates/docs/adr/ADR-stack-template.md`. The SPEC declares the milestone it is
|
|
135
|
+
compared against: frontmatter `go_live: YYYY-MM-DD` or a `**Go-live:** YYYY-MM-DD` line.
|
|
136
|
+
`qa_ledger.py spec-check` then reports, per component, `ok` / `expires before go-live` /
|
|
137
|
+
`no EOL cited` / `no source cited` — ADVISORY, it never gates. It can see that a date
|
|
138
|
+
was CITED; it cannot see whether the citation is TRUE. That part is yours.
|
|
139
|
+
4. **Behavior.** Happy path first, then the DIRTY cases: provider/timeout failures,
|
|
116
140
|
retries and backoff, 4xx vs 5xx, concurrency, partial/terminal states, what must NOT
|
|
117
141
|
happen. A feature without its failure behavior is half-specified.
|
|
118
|
-
|
|
142
|
+
5. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
|
|
119
143
|
rules that cannot be broken (money to the cent, numbering without gaps, never cross
|
|
120
144
|
environments/credentials, secrets never logged, auth/authz, data retention). Write/extend
|
|
121
145
|
`CONSTITUTION.md` (one invariant per line, CWE ref where it maps); these feed the
|
|
122
146
|
dev-loop severity gate and a breach is a BLOCKER. **An ADR may never contradict the
|
|
123
147
|
CONSTITUTION** — if a decision would, it's escalated, not recorded.
|
|
124
|
-
|
|
148
|
+
6. **Out of scope.** Explicit boundaries, with forward references ("X goes to a later
|
|
125
149
|
spec"). What you exclude is as important as what you include.
|
|
126
|
-
|
|
150
|
+
7. **Definition of Done + how we measure success.** Concrete, checkable acceptance criteria
|
|
127
151
|
(tests green, documented, metrics published, runbook) AND success metrics (p95,
|
|
128
152
|
cost ceiling, zero orphaned records). Each item must be verifiable, not a feeling.
|
|
129
|
-
|
|
153
|
+
8. **Dependencies.** Which other specs/systems/credentials this needs to exist first.
|
|
130
154
|
|
|
131
155
|
After each batch, reflect: "Decided: … / Still open: …". Move on only when the
|
|
132
156
|
current topic is closed.
|
|
@@ -146,7 +170,7 @@ have behavior, the scope has a boundary and the DoD is verifiable.") before Phas
|
|
|
146
170
|
## Phase B — Distill the artifacts
|
|
147
171
|
|
|
148
172
|
Only after convergence. Produce (and, if any new project-wide invariant surfaced in
|
|
149
|
-
step
|
|
173
|
+
step 5, append it to **`CONSTITUTION.md`** — never let an inviolable rule slip into an
|
|
150
174
|
ADR where it could later be "traded away"):
|
|
151
175
|
|
|
152
176
|
1. **One ADR per decision worth recording** at `docs/adr/ADR-NNN-<slug>.md`, format:
|
|
@@ -206,17 +206,51 @@ def _repo_cfg(ledger, name):
|
|
|
206
206
|
# measurement: coverage
|
|
207
207
|
# --------------------------------------------------------------------------- #
|
|
208
208
|
def _jacoco_line_counter(xml_path):
|
|
209
|
-
"""Return (missed, covered) for the report-level LINE counter
|
|
209
|
+
"""Return (missed, covered) for the report-level LINE counter, or None when the report
|
|
210
|
+
could not be READ. The distinction is the whole point: a report that measured nothing
|
|
211
|
+
must never be summed as (0, 0) — that is an invented number, and with one module's XML
|
|
212
|
+
truncated mid-write it made the surviving modules' percentage look like the project's
|
|
213
|
+
(audit 1.94.1). Absence is never invented as a number — the rule `cobertura_coverage`
|
|
214
|
+
already states, applied to the JaCoCo readers too. A report with no LINE counter at all
|
|
215
|
+
is a real, readable zero and stays (0, 0)."""
|
|
210
216
|
try:
|
|
211
217
|
root = _parse_xml(xml_path).getroot()
|
|
212
|
-
except ET.ParseError:
|
|
213
|
-
return
|
|
218
|
+
except (ET.ParseError, OSError, ReportTooLarge):
|
|
219
|
+
return None
|
|
214
220
|
for c in root.findall("counter"):
|
|
215
221
|
if c.get("type") == "LINE":
|
|
216
|
-
|
|
222
|
+
try:
|
|
223
|
+
return int(c.get("missed", 0)), int(c.get("covered", 0))
|
|
224
|
+
except (TypeError, ValueError):
|
|
225
|
+
return None # a counter that is not a number measured nothing
|
|
217
226
|
return 0, 0
|
|
218
227
|
|
|
219
228
|
|
|
229
|
+
def _jacoco_result(files):
|
|
230
|
+
"""Sum the LINE counters of `files`, or report the WHOLE reading unmeasured. Shared by
|
|
231
|
+
maven/gradle/ant: three hand-copied versions of this sum is how one of them would drift.
|
|
232
|
+
|
|
233
|
+
ONE unreadable report makes the reading unmeasured, and an unmeasured reading is
|
|
234
|
+
(0, 0, 0.0) -- not the survivors' percentage with a false flag beside it. Every other
|
|
235
|
+
reader in this file returns pct 0.0 whenever report_found is False, and readers of the
|
|
236
|
+
result rely on it: `readiness` scores coverage as pct/threshold WITHOUT consulting
|
|
237
|
+
report_found, and `snapshot` prints and persists the pct next to the flag. Handing them
|
|
238
|
+
the surviving modules' number would score a project on a question nobody asked
|
|
239
|
+
(audit 1.94.1). The report paths are still listed -- the operator needs to know which
|
|
240
|
+
files were looked at."""
|
|
241
|
+
parsed = [_jacoco_line_counter(f) for f in files]
|
|
242
|
+
reports = [f.replace("\\", "/") for f in files]
|
|
243
|
+
if not files or any(p is None for p in parsed):
|
|
244
|
+
return {"covered": 0, "missed": 0, "pct": 0.0, "report_found": False,
|
|
245
|
+
"reports": reports}
|
|
246
|
+
missed = sum(p[0] for p in parsed)
|
|
247
|
+
covered = sum(p[1] for p in parsed)
|
|
248
|
+
total = missed + covered
|
|
249
|
+
pct = round(covered / total * 100, 2) if total else 0.0
|
|
250
|
+
return {"covered": covered, "missed": missed, "pct": pct,
|
|
251
|
+
"report_found": True, "reports": reports}
|
|
252
|
+
|
|
253
|
+
|
|
220
254
|
def maven_coverage(repo_path):
|
|
221
255
|
"""
|
|
222
256
|
Prefer an aggregate report if present, else sum per-module reports.
|
|
@@ -231,16 +265,7 @@ def maven_coverage(repo_path):
|
|
|
231
265
|
files = glob.glob(os.path.join(repo_path, "**", "target", "site",
|
|
232
266
|
"jacoco", "jacoco.xml"),
|
|
233
267
|
recursive=True)
|
|
234
|
-
|
|
235
|
-
for f in files:
|
|
236
|
-
m, c = _jacoco_line_counter(f)
|
|
237
|
-
missed += m
|
|
238
|
-
covered += c
|
|
239
|
-
total = missed + covered
|
|
240
|
-
pct = round(covered / total * 100, 2) if total else 0.0
|
|
241
|
-
return {"covered": covered, "missed": missed, "pct": pct,
|
|
242
|
-
"report_found": bool(files),
|
|
243
|
-
"reports": [f.replace("\\", "/") for f in files]}
|
|
268
|
+
return _jacoco_result(files)
|
|
244
269
|
|
|
245
270
|
|
|
246
271
|
def flutter_coverage(repo_path):
|
|
@@ -343,16 +368,7 @@ def gradle_coverage(repo_path):
|
|
|
343
368
|
if not files:
|
|
344
369
|
files = glob.glob(os.path.join(repo_path, "**", "build", "reports",
|
|
345
370
|
"jacoco", "jacoco.xml"), recursive=True)
|
|
346
|
-
|
|
347
|
-
for f in files:
|
|
348
|
-
m, c = _jacoco_line_counter(f)
|
|
349
|
-
missed += m
|
|
350
|
-
covered += c
|
|
351
|
-
total = missed + covered
|
|
352
|
-
pct = round(covered / total * 100, 2) if total else 0.0
|
|
353
|
-
return {"covered": covered, "missed": missed, "pct": pct,
|
|
354
|
-
"report_found": bool(files),
|
|
355
|
-
"reports": [f.replace("\\", "/") for f in files]}
|
|
371
|
+
return _jacoco_result(files)
|
|
356
372
|
|
|
357
373
|
|
|
358
374
|
def ant_coverage(repo_path):
|
|
@@ -360,16 +376,7 @@ def ant_coverage(repo_path):
|
|
|
360
376
|
report task writes wherever the build file says -- so the report is discovered
|
|
361
377
|
RECURSIVELY by name instead of guessing one convention."""
|
|
362
378
|
files = _ant_reports(repo_path, "jacoco.xml")
|
|
363
|
-
|
|
364
|
-
for f in files:
|
|
365
|
-
m, c = _jacoco_line_counter(f)
|
|
366
|
-
missed += m
|
|
367
|
-
covered += c
|
|
368
|
-
total = missed + covered
|
|
369
|
-
pct = round(covered / total * 100, 2) if total else 0.0
|
|
370
|
-
return {"covered": covered, "missed": missed, "pct": pct,
|
|
371
|
-
"report_found": bool(files),
|
|
372
|
-
"reports": [f.replace("\\", "/") for f in files]}
|
|
379
|
+
return _jacoco_result(files)
|
|
373
380
|
|
|
374
381
|
|
|
375
382
|
def coverage(repo_path, repo_type):
|
|
@@ -1276,7 +1283,15 @@ def _mk_id(tool, rule, fname, line, granularity):
|
|
|
1276
1283
|
|
|
1277
1284
|
def _find_all(base, patterns, explicit):
|
|
1278
1285
|
if explicit:
|
|
1279
|
-
|
|
1286
|
+
if not os.path.exists(explicit):
|
|
1287
|
+
# An EXPLICIT path is a claim the operator made about where the report is. When
|
|
1288
|
+
# it is not there, the honest reading is a typo or a build that never wrote it --
|
|
1289
|
+
# not "this linter has no findings". Returning [] made ingest-gate log nothing
|
|
1290
|
+
# and exit 0, so a mistyped --ruff read as a clean gate (audit 1.94.1). Same
|
|
1291
|
+
# fail-closed exit an unparseable report already gets, with the path named.
|
|
1292
|
+
_invalid_static_report(explicit, "linter",
|
|
1293
|
+
"no such file (explicit path given, nothing to ingest)")
|
|
1294
|
+
return [explicit]
|
|
1280
1295
|
found = []
|
|
1281
1296
|
for pat in patterns:
|
|
1282
1297
|
found += glob.glob(os.path.join(base, pat), recursive=True)
|
|
@@ -3107,7 +3122,7 @@ def cmd_fastpath_eval(args):
|
|
|
3107
3122
|
sig("configured", False, "defaults.fast_path present and enabled",
|
|
3108
3123
|
"config.defaults.fast_path", False)
|
|
3109
3124
|
else:
|
|
3110
|
-
repo_path =
|
|
3125
|
+
repo_path = _scope_path(ledger, args.repo)
|
|
3111
3126
|
base, base_src = args.base, "--base"
|
|
3112
3127
|
if base:
|
|
3113
3128
|
probe = subprocess.run(["git", "rev-parse", "--verify", base + "^{commit}"],
|
|
@@ -3694,7 +3709,7 @@ def cmd_cleanroom(args):
|
|
|
3694
3709
|
import time
|
|
3695
3710
|
ledger = _load(args.ledger)
|
|
3696
3711
|
_repo_node(ledger, args.repo)
|
|
3697
|
-
repo_path =
|
|
3712
|
+
repo_path = _scope_path(ledger, args.repo)
|
|
3698
3713
|
|
|
3699
3714
|
def git(*a, **kw):
|
|
3700
3715
|
cwd = kw.pop("cwd", repo_path)
|
|
@@ -8406,6 +8421,10 @@ def cmd_dashboard(args):
|
|
|
8406
8421
|
for r in {e.get("repo") for e in ledger["fast_path"]}}
|
|
8407
8422
|
if ledger.get("spec_drift"):
|
|
8408
8423
|
out["spec_drift"] = ledger["spec_drift"]
|
|
8424
|
+
# lifecycle (ADR-040): reused VERBATIM from the readiness payload above (one read, no
|
|
8425
|
+
# drift possible between the two surfaces), conditional for the same reason spec_drift is.
|
|
8426
|
+
if rd.get("lifecycle"):
|
|
8427
|
+
out["lifecycle"] = rd["lifecycle"]
|
|
8409
8428
|
# evidence_origin: the latest snapshot's origin per repo, and ONLY when one exists --
|
|
8410
8429
|
# a ledger predating ADR-007 keeps the exact prior schema (same conditional-key rule
|
|
8411
8430
|
# fast_path and spec_drift already follow).
|
|
@@ -9554,6 +9573,12 @@ def cmd_readiness(args):
|
|
|
9554
9573
|
"gates": _gate_rollup(ledger),
|
|
9555
9574
|
"by_repo": repos,
|
|
9556
9575
|
}
|
|
9576
|
+
# lifecycle (ADR-040): advisory, and CONDITIONAL like fast_path/spec_drift -- a project
|
|
9577
|
+
# that declares no lifecycle: block keeps the exact prior payload and the exact prior
|
|
9578
|
+
# text. Speaking only when it matters is the anti-ceremony rule applied to itself.
|
|
9579
|
+
_lc = _lifecycle_for(os.path.dirname(os.path.abspath(args.ledger)) or os.getcwd())
|
|
9580
|
+
if _lc["declared"]:
|
|
9581
|
+
out["lifecycle"] = _lc
|
|
9557
9582
|
if args.json:
|
|
9558
9583
|
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
9559
9584
|
return
|
|
@@ -9634,6 +9659,8 @@ def cmd_readiness(args):
|
|
|
9634
9659
|
f"rising for {STALL_WINDOW} cycles: iterating more is not getting "
|
|
9635
9660
|
f"closer. Likely a design/SPEC problem — go back to the ADR / "
|
|
9636
9661
|
f"re-plan with the human (advisory)")
|
|
9662
|
+
if _lc["declared"]:
|
|
9663
|
+
print(" · " + _lifecycle_summary(_lc))
|
|
9637
9664
|
# rubrica (1.23.0): el ultimo grade por repo, siempre visible — guess
|
|
9638
9665
|
# estructurado que aconseja; si esta gateado ya bloqueo por el ledger
|
|
9639
9666
|
for rname, rnode in ledger["repos"].items():
|
|
@@ -10986,6 +11013,225 @@ _SC_SETEXT = re.compile(r"^(=+|-{2,})\s*$")
|
|
|
10986
11013
|
_SC_CHECKBOX = re.compile(r"^\[[ xX]\]\s*")
|
|
10987
11014
|
|
|
10988
11015
|
|
|
11016
|
+
# --------------------------------------------------------------------------- #
|
|
11017
|
+
# lifecycle (ADR-040: stack end-of-support vs go-live -- advisory, NEVER gates)
|
|
11018
|
+
# --------------------------------------------------------------------------- #
|
|
11019
|
+
# The method interrogates WHAT and WHY and has always treated the stack as a given
|
|
11020
|
+
# ("we use X"). A major line is not a decision: a minor line has an END-OF-SUPPORT
|
|
11021
|
+
# DATE, and a date that falls before the declared go-live is a forced upgrade nobody
|
|
11022
|
+
# planned. This dimension reads what the ADR CITES and compares it against the SPEC's
|
|
11023
|
+
# declared go-live. It cannot verify that the cited source tells the truth -- only that
|
|
11024
|
+
# a date and a source were cited at all. Advisory by construction, like spec-drift
|
|
11025
|
+
# (ADR-005): it never gates, never caps readiness, and never changes an exit code.
|
|
11026
|
+
_LC_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
11027
|
+
|
|
11028
|
+
|
|
11029
|
+
def _lc_valid_date(s):
|
|
11030
|
+
"""A cited date is real only if it is BOTH shaped YYYY-MM-DD and a calendar date:
|
|
11031
|
+
the format regex alone accepts 2025-13-40 (1.94.0 review), which then string-compares
|
|
11032
|
+
as if it were a real EOL. fromisoformat rejects the impossible month/day."""
|
|
11033
|
+
if not s or not _LC_DATE.match(s):
|
|
11034
|
+
return False
|
|
11035
|
+
try:
|
|
11036
|
+
datetime.strptime(s, "%Y-%m-%d")
|
|
11037
|
+
return True
|
|
11038
|
+
except ValueError:
|
|
11039
|
+
return False
|
|
11040
|
+
_LC_KEY = re.compile(r"^lifecycle\s*:\s*(.*)$")
|
|
11041
|
+
_LC_GOLIVE_FM = re.compile(r"^go[-_ ]?live\s*:\s*(.+?)\s*$", re.I)
|
|
11042
|
+
_LC_GOLIVE_LINE = re.compile(r"^\s*\*\*\s*go[-_ ]?live\s*:?\s*\*\*\s*:?\s*"
|
|
11043
|
+
r"(\d{4}-\d{2}-\d{2})\b", re.I)
|
|
11044
|
+
_LC_FIELDS = ("component", "version", "eol", "source", "checked")
|
|
11045
|
+
|
|
11046
|
+
|
|
11047
|
+
def _lc_frontmatter(lines):
|
|
11048
|
+
"""The lines INSIDE a leading `---` frontmatter block, or None when there is none.
|
|
11049
|
+
An unterminated fence is not data: a half-written block reads as absent."""
|
|
11050
|
+
if not lines or lines[0].strip() != "---":
|
|
11051
|
+
return None
|
|
11052
|
+
body = []
|
|
11053
|
+
for ln in lines[1:]:
|
|
11054
|
+
if ln.strip() == "---":
|
|
11055
|
+
return body
|
|
11056
|
+
body.append(ln)
|
|
11057
|
+
return None
|
|
11058
|
+
|
|
11059
|
+
|
|
11060
|
+
def _lc_parse(lines):
|
|
11061
|
+
"""The `lifecycle:` list-of-dicts of a markdown file, parsed as a YAML SUBSET --
|
|
11062
|
+
stdlib only, the kit ships no PyYAML. Returns None when the key is absent (nothing to
|
|
11063
|
+
measure) and [] when the key is there but nothing under it parses (a NAMED absence:
|
|
11064
|
+
the caller reports UNMEASURED with a reason, never a silent pass)."""
|
|
11065
|
+
fm = _lc_frontmatter(lines)
|
|
11066
|
+
if fm is None:
|
|
11067
|
+
return None
|
|
11068
|
+
entries, in_lc, cur = None, False, None
|
|
11069
|
+
for ln in fm:
|
|
11070
|
+
s = ln.strip()
|
|
11071
|
+
if not in_lc:
|
|
11072
|
+
m = _LC_KEY.match(ln)
|
|
11073
|
+
if m:
|
|
11074
|
+
entries = []
|
|
11075
|
+
# only the block form is supported; an inline `lifecycle: [...]` parses to
|
|
11076
|
+
# nothing and reads as unparseable, never as "this ADR fixes no component"
|
|
11077
|
+
in_lc = not m.group(1).strip()
|
|
11078
|
+
continue
|
|
11079
|
+
if not s or s.startswith("#"):
|
|
11080
|
+
continue
|
|
11081
|
+
if not ln[:1].isspace(): # back to column 0 -> the key ended
|
|
11082
|
+
in_lc = False
|
|
11083
|
+
continue
|
|
11084
|
+
if s.startswith("- "):
|
|
11085
|
+
cur = {}
|
|
11086
|
+
entries.append(cur)
|
|
11087
|
+
s = s[2:].strip()
|
|
11088
|
+
if not s:
|
|
11089
|
+
continue
|
|
11090
|
+
if cur is None:
|
|
11091
|
+
continue
|
|
11092
|
+
k, sep, v = s.partition(":")
|
|
11093
|
+
if not sep:
|
|
11094
|
+
continue
|
|
11095
|
+
k = k.strip().lower()
|
|
11096
|
+
if k in _LC_FIELDS:
|
|
11097
|
+
cur[k] = v.strip().strip("\x27\x22")
|
|
11098
|
+
if entries is not None:
|
|
11099
|
+
entries = [e for e in entries if e]
|
|
11100
|
+
return entries
|
|
11101
|
+
|
|
11102
|
+
|
|
11103
|
+
def _lc_go_live(text):
|
|
11104
|
+
"""The declared go-live of a SPEC: frontmatter `go_live: YYYY-MM-DD`, or a
|
|
11105
|
+
`**Go-live:** YYYY-MM-DD` line anywhere in the body. None = not declared."""
|
|
11106
|
+
lines = [ln.rstrip("\r") for ln in (text or "").split("\n")]
|
|
11107
|
+
fm = _lc_frontmatter(lines)
|
|
11108
|
+
if fm:
|
|
11109
|
+
for ln in fm:
|
|
11110
|
+
if ln[:1].isspace():
|
|
11111
|
+
continue
|
|
11112
|
+
m = _LC_GOLIVE_FM.match(ln.strip())
|
|
11113
|
+
if m:
|
|
11114
|
+
v = m.group(1).strip().strip("\x27\x22")
|
|
11115
|
+
if _lc_valid_date(v):
|
|
11116
|
+
return v
|
|
11117
|
+
for ln in lines:
|
|
11118
|
+
m = _LC_GOLIVE_LINE.match(ln)
|
|
11119
|
+
if m and _lc_valid_date(m.group(1)):
|
|
11120
|
+
return m.group(1)
|
|
11121
|
+
return None
|
|
11122
|
+
|
|
11123
|
+
|
|
11124
|
+
def _lc_short(path):
|
|
11125
|
+
"""The ADR dir as the reader would type it: relative to the cwd when that is shorter,
|
|
11126
|
+
absolute otherwise (a different drive on Windows has no relative form)."""
|
|
11127
|
+
if not path:
|
|
11128
|
+
return "docs/adr"
|
|
11129
|
+
try:
|
|
11130
|
+
rel = os.path.relpath(path)
|
|
11131
|
+
except ValueError:
|
|
11132
|
+
return path
|
|
11133
|
+
return rel if len(rel) < len(path) else path
|
|
11134
|
+
|
|
11135
|
+
|
|
11136
|
+
def _lifecycle_report(adr_dir, spec_text):
|
|
11137
|
+
"""Per-component verdicts for every `lifecycle:` entry under `adr_dir`, compared
|
|
11138
|
+
against the go-live declared in `spec_text`. Whole-dimension UNMEASURED -- with the
|
|
11139
|
+
reason spelled out, never silence -- when no ADR carries the block, when the block is
|
|
11140
|
+
unparseable, or when no go-live is declared."""
|
|
11141
|
+
go_live = _lc_go_live(spec_text)
|
|
11142
|
+
comps, unparsed, names = [], [], []
|
|
11143
|
+
if adr_dir and os.path.isdir(adr_dir):
|
|
11144
|
+
names = sorted(f for f in os.listdir(adr_dir) if f.lower().endswith(".md"))
|
|
11145
|
+
for fn in names:
|
|
11146
|
+
try:
|
|
11147
|
+
with open(os.path.join(adr_dir, fn), "r", encoding="utf-8",
|
|
11148
|
+
errors="replace") as fh:
|
|
11149
|
+
lines = [ln.rstrip("\r") for ln in fh.read().split("\n")]
|
|
11150
|
+
except OSError:
|
|
11151
|
+
continue
|
|
11152
|
+
ent = _lc_parse(lines)
|
|
11153
|
+
if ent is None:
|
|
11154
|
+
continue
|
|
11155
|
+
if not ent:
|
|
11156
|
+
unparsed.append(fn)
|
|
11157
|
+
continue
|
|
11158
|
+
for e in ent:
|
|
11159
|
+
comps.append({"adr": fn, "component": e.get("component") or "(unnamed)",
|
|
11160
|
+
"version": e.get("version"), "eol": e.get("eol"),
|
|
11161
|
+
"source": e.get("source"), "checked": e.get("checked")})
|
|
11162
|
+
out = {"status": "UNMEASURED", "reason": None, "go_live": go_live,
|
|
11163
|
+
"adr_dir": adr_dir, "declared": bool(comps or unparsed),
|
|
11164
|
+
"components": comps, "n_components": len(comps), "n_expiring": 0,
|
|
11165
|
+
"unparsed_adrs": unparsed}
|
|
11166
|
+
if not comps:
|
|
11167
|
+
out["reason"] = ("lifecycle: block declared but unparseable in: "
|
|
11168
|
+
+ ", ".join(unparsed) if unparsed else
|
|
11169
|
+
"no ADR under %s carries a lifecycle: block"
|
|
11170
|
+
% _lc_short(adr_dir))
|
|
11171
|
+
return out
|
|
11172
|
+
if not go_live:
|
|
11173
|
+
for c in comps:
|
|
11174
|
+
c["status"], c["detail"] = "unmeasured", "no go-live declared"
|
|
11175
|
+
out["reason"] = ("no go-live declared in the SPEC (frontmatter 'go_live: "
|
|
11176
|
+
"YYYY-MM-DD' or a '**Go-live:** YYYY-MM-DD' line)")
|
|
11177
|
+
return out
|
|
11178
|
+
for c in comps:
|
|
11179
|
+
eol = (c.get("eol") or "").strip()
|
|
11180
|
+
cited = (c.get("source") or "").strip()
|
|
11181
|
+
if not eol or eol.lower() == "unknown" or not _lc_valid_date(eol):
|
|
11182
|
+
c["status"] = "no EOL cited"
|
|
11183
|
+
c["detail"] = ("declared unknown" if eol.lower() == "unknown"
|
|
11184
|
+
else ("not a YYYY-MM-DD date: %s" % eol if eol else "absent"))
|
|
11185
|
+
elif eol < go_live:
|
|
11186
|
+
# the sharper fact wins the label: a date that already expires is reported as
|
|
11187
|
+
# expiring even when uncited, and the missing source stays visible in the record
|
|
11188
|
+
c["status"] = "expires before go-live"
|
|
11189
|
+
c["detail"] = "%s < %s" % (eol, go_live)
|
|
11190
|
+
out["n_expiring"] += 1
|
|
11191
|
+
elif not cited:
|
|
11192
|
+
c["status"] = "no source cited"
|
|
11193
|
+
c["detail"] = "eol %s is asserted, not cited" % eol
|
|
11194
|
+
else:
|
|
11195
|
+
c["status"] = "ok"
|
|
11196
|
+
c["detail"] = "eol %s >= go-live %s" % (eol, go_live)
|
|
11197
|
+
out["status"] = "measured"
|
|
11198
|
+
return out
|
|
11199
|
+
|
|
11200
|
+
|
|
11201
|
+
def _lifecycle_summary(lc):
|
|
11202
|
+
"""The one advisory line, shared by spec-check and readiness so the two surfaces
|
|
11203
|
+
cannot drift apart."""
|
|
11204
|
+
if lc["status"] != "measured":
|
|
11205
|
+
return "lifecycle: UNMEASURED - %s" % lc["reason"]
|
|
11206
|
+
return ("lifecycle: %d component(s), %d expire before go-live %s (advisory)"
|
|
11207
|
+
% (lc["n_components"], lc["n_expiring"], lc["go_live"]))
|
|
11208
|
+
|
|
11209
|
+
|
|
11210
|
+
def _lifecycle_root(*paths):
|
|
11211
|
+
"""The project root a lifecycle read hangs off: the directory of the first path
|
|
11212
|
+
given, else the cwd."""
|
|
11213
|
+
for p in paths:
|
|
11214
|
+
if p:
|
|
11215
|
+
return os.path.dirname(os.path.abspath(p)) or os.getcwd()
|
|
11216
|
+
return os.getcwd()
|
|
11217
|
+
|
|
11218
|
+
|
|
11219
|
+
def _lifecycle_for(root, adr_dir=None, spec_text=None, fallback=True):
|
|
11220
|
+
"""Read the dimension for a project root: `docs/adr` unless overridden, and the
|
|
11221
|
+
go-live from the text already in hand. `fallback` reads `<root>/SPEC.md` when no
|
|
11222
|
+
text was supplied -- it is OFF when the caller named the spec files itself, so an
|
|
11223
|
+
explicit `--spec` is never silently topped up from a file nobody asked for."""
|
|
11224
|
+
if fallback and not _lc_go_live(spec_text):
|
|
11225
|
+
sp = os.path.join(root, "SPEC.md")
|
|
11226
|
+
if os.path.isfile(sp):
|
|
11227
|
+
try:
|
|
11228
|
+
with open(sp, "r", encoding="utf-8", errors="replace") as fh:
|
|
11229
|
+
spec_text = (spec_text or "") + "\n" + fh.read()
|
|
11230
|
+
except OSError:
|
|
11231
|
+
pass
|
|
11232
|
+
return _lifecycle_report(adr_dir or os.path.join(root, "docs", "adr"), spec_text)
|
|
11233
|
+
|
|
11234
|
+
|
|
10989
11235
|
def _spec_check_text(text):
|
|
10990
11236
|
lines = text.split("\n")
|
|
10991
11237
|
n = len(lines)
|
|
@@ -11280,6 +11526,11 @@ def cmd_spec_check(args):
|
|
|
11280
11526
|
m = (_spec_check_text(text) if text.strip()
|
|
11281
11527
|
else {"blockers": [], "untestable": [], "stack_hits": [],
|
|
11282
11528
|
"non_ears": 0, "n_criteria": 0})
|
|
11529
|
+
# lifecycle (ADR-040): read-only and advisory -- it never touches `fail` below.
|
|
11530
|
+
lc = _lifecycle_for(_lifecycle_root(args.spec[0] if args.spec else None,
|
|
11531
|
+
args.acceptance),
|
|
11532
|
+
getattr(args, "adr_dir", None), text,
|
|
11533
|
+
fallback=not args.spec)
|
|
11283
11534
|
structural = len(m["blockers"]) + len(acc_block) # estructura = FACT -> bloquea
|
|
11284
11535
|
soft_find = len(m["untestable"]) + len(m["stack_hits"]) + len(acc_adv)
|
|
11285
11536
|
fail = structural > 0 or (args.strict and soft_find > 0)
|
|
@@ -11288,7 +11539,7 @@ def cmd_spec_check(args):
|
|
|
11288
11539
|
if args.json:
|
|
11289
11540
|
print(json.dumps({"verdict": verdict, "advisory": structural == 0,
|
|
11290
11541
|
"acceptance_blockers": acc_block,
|
|
11291
|
-
"acceptance_advisory": acc_adv, **m},
|
|
11542
|
+
"acceptance_advisory": acc_adv, "lifecycle": lc, **m},
|
|
11292
11543
|
indent=2, ensure_ascii=False))
|
|
11293
11544
|
sys.exit(1 if fail else 0)
|
|
11294
11545
|
|
|
@@ -11309,6 +11560,11 @@ def cmd_spec_check(args):
|
|
|
11309
11560
|
print(f" ~ stack named in criteria ({len(m['stack_hits'])}): {', '.join(terms[:6])} — the 'how' belongs in the ADR, not the SPEC")
|
|
11310
11561
|
if m["non_ears"] and m["n_criteria"]:
|
|
11311
11562
|
print(f" · advisory: {m['non_ears']}/{m['n_criteria']} criteria do not follow the EARS pattern (When/If/While … shall)")
|
|
11563
|
+
print(" · " + _lifecycle_summary(lc))
|
|
11564
|
+
for c in [x for x in lc["components"] if x.get("status") not in ("ok", None)][:5]:
|
|
11565
|
+
print(" %s %s %s - %s (%s)"
|
|
11566
|
+
% ("!" if c["status"] == "expires before go-live" else "~",
|
|
11567
|
+
c["component"], c.get("version") or "?", c["status"], c["detail"]))
|
|
11312
11568
|
print(" i consistency: INFERENTIAL (an uncorrelated checker), not this lint · "
|
|
11313
11569
|
"structure = FACT (blocks) · prose = advisory (--strict to gate)")
|
|
11314
11570
|
if verdict == "OK":
|
|
@@ -12529,6 +12785,9 @@ def build_parser():
|
|
|
12529
12785
|
help="validate ACCEPTANCE traceability (AC-n IDs): missing "
|
|
12530
12786
|
"file / zero criteria / zero traceable / duplicate "
|
|
12531
12787
|
"IDs block as structural FACTS")
|
|
12788
|
+
psc.add_argument("--adr-dir", default=None,
|
|
12789
|
+
help="ADRs whose lifecycle: blocks are read (ADR-040); default "
|
|
12790
|
+
"docs/adr beside the SPEC")
|
|
12532
12791
|
psc.add_argument("--strict", action="store_true",
|
|
12533
12792
|
help="also fail (exit 1) on soft findings: untestable criteria / stack named")
|
|
12534
12793
|
psc.add_argument("--json", action="store_true")
|
|
@@ -137,25 +137,49 @@ answer.
|
|
|
137
137
|
I deduce these core entities: … do they work for you, or is one missing?")
|
|
138
138
|
3. **Operation / API surface.** Propose the endpoints/operations and their
|
|
139
139
|
contracts (idempotency, status codes).
|
|
140
|
-
4. **
|
|
140
|
+
4. **Stack and lifecycle (MANDATORY — before any stack/architecture decision is
|
|
141
|
+
recorded).** The stack is not a given ("we use X"): it is a decision with an EXPIRY
|
|
142
|
+
DATE. A major version is a family; support is granted to a MINOR line, for a window,
|
|
143
|
+
by an upstream nobody in the room controls. Ask these one at a time, each with your
|
|
144
|
+
recommended answer, and **FETCH the dates from the official source AS YOU ASK — never
|
|
145
|
+
answer from memory**; record the URL and the day you checked:
|
|
146
|
+
(a) the EXACT version of every runtime/framework/store (JDK, web framework, ORM, DB,
|
|
147
|
+
Node, bundler, broker, cache) and its OSS/LTS end-of-support date, cited;
|
|
148
|
+
(b) support window vs the declared go-live AND the expected operating life — does the
|
|
149
|
+
line stay patched for the whole operation? If not, move it up BEFORE building: a major
|
|
150
|
+
upgrade days before the milestone is the most expensive one there is;
|
|
151
|
+
(c) major dependencies and the upgrade policy — who approves one, and when it is
|
|
152
|
+
scheduled (aligned with the dev-loop's "zero new dependencies without approval");
|
|
153
|
+
(d) development and observability tools the operator wants from day one (consoles, APM,
|
|
154
|
+
admin UIs) — they CONSTRAIN versions, so ask early or they force the upgrade;
|
|
155
|
+
(e) compatibility with reused legacy modules — the minimum version they support.
|
|
156
|
+
The answers distil into the stack ADR with the dates INSIDE it, as the machine-readable
|
|
157
|
+
`lifecycle:` frontmatter block (`component` / `version` / `eol` / `source` / `checked`,
|
|
158
|
+
ISO dates; `eol: unknown` is allowed and reads as a NAMED absence, never a pass) — see
|
|
159
|
+
`templates/docs/adr/ADR-stack-template.md`. The SPEC declares the milestone it is
|
|
160
|
+
compared against: frontmatter `go_live: YYYY-MM-DD` or a `**Go-live:** YYYY-MM-DD` line.
|
|
161
|
+
`qa_ledger.py spec-check` then reports, per component, `ok` / `expires before go-live` /
|
|
162
|
+
`no EOL cited` / `no source cited` — ADVISORY, it never gates. It can see that a date
|
|
163
|
+
was CITED; it cannot see whether the citation is TRUE. That part is yours.
|
|
164
|
+
5. **Big decisions (→ ADR).** Propose 2–3 architecture options with trade-offs and
|
|
141
165
|
a recommended default: persistence, protocol, idempotency, sync/async, multi-tenancy.
|
|
142
|
-
|
|
166
|
+
6. **Behavior and dirty cases.** Happy path, then failures, retries, partial states,
|
|
143
167
|
concurrency, what must NOT happen.
|
|
144
|
-
|
|
168
|
+
7. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
|
|
145
169
|
rules that can't be broken (money to the cent, no numbering gaps, never cross
|
|
146
170
|
environments, secrets never logged, auth). Write/extend `CONSTITUTION.md` with these —
|
|
147
171
|
one invariant per line, with a CWE reference where it maps. They feed the severity gate
|
|
148
172
|
downstream, and a breach is a BLOCKER, never a trade-off.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
173
|
+
8. **Out of scope.** Explicit boundaries with forward references.
|
|
174
|
+
9. **Acceptance / Definition of Done.** Concrete, checkable criteria + success metrics.
|
|
175
|
+
10. **Quality bar (→ config, kit 1.17.0).** "What level of quality is ENOUGH here, and which
|
|
152
176
|
dimensions are negotiable (coverage, perf, security)?" Propose thresholds fit for
|
|
153
177
|
the risk profile (a payments core is not an internal dashboard). What the human
|
|
154
178
|
declares goes into `uscha.config.json` (`defaults.coverage_threshold`,
|
|
155
179
|
`defaults.readiness_caps`, `defaults.simplicity`) — a declared threshold reads as
|
|
156
180
|
**requerimiento (config)** in the engine's output; an undeclared one stays a kit
|
|
157
181
|
default (opinion) and is labeled as such. Declaring is committing the config.
|
|
158
|
-
|
|
182
|
+
11. **Residual risks and dependencies.** What's uncertain, what must exist first.
|
|
159
183
|
For each HIGH-uncertainty risk, ask (kit 1.19.0, Tip 21 'Prototype to Learn'):
|
|
160
184
|
"Does it warrant a time-boxed spike before freezing the SPEC?" A spike runs on a
|
|
161
185
|
`spike/*` branch and its ONLY legitimate output is an **ADR with lessons**
|
|
@@ -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.94.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.94.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.94.1
|