@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 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.93.1** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
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.93.1, 53 subcommands, all measured):**
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.93.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. **Behavior.** Happy path first, then the DIRTY cases: provider/timeout failures,
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
- 4. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
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
- 5. **Out of scope.** Explicit boundaries, with forward references ("X goes to a later
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
- 6. **Definition of Done + how we measure success.** Concrete, checkable acceptance criteria
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
- 7. **Dependencies.** Which other specs/systems/credentials this needs to exist first.
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 4, append it to **`CONSTITUTION.md`** — never let an inviolable rule slip into an
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 0, 0
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
- return int(c.get("missed", 0)), int(c.get("covered", 0))
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
- missed = covered = 0
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
- missed = covered = 0
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
- missed = covered = 0
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
- return [explicit] if os.path.exists(explicit) else []
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 = _repo_cfg(ledger, args.repo).get("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 = _repo_cfg(ledger, args.repo).get("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. **Big decisions ( ADR).** Propose 2–3 architecture options with trade-offs and
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
- 5. **Behavior and dirty cases.** Happy path, then failures, retries, partial states,
166
+ 6. **Behavior and dirty cases.** Happy path, then failures, retries, partial states,
143
167
  concurrency, what must NOT happen.
144
- 6. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
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
- 7. **Out of scope.** Explicit boundaries with forward references.
150
- 8. **Acceptance / Definition of Done.** Concrete, checkable criteria + success metrics.
151
- 9. **Quality bar (→ config, kit 1.17.0).** "What level of quality is ENOUGH here, and which
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
- 10. **Residual risks and dependencies.** What's uncertain, what must exist first.
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.93.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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uscha",
3
- "version": "1.93.1",
3
+ "version": "1.94.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.93.1 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
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.93.1
1
+ uscha-kit 1.94.1