@andresmassello/uscha 1.86.1 → 1.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +4 -1
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +265 -30
- package/uscha-kit/.claude/skills/uscha-devloop/uscha_top.py +176 -30
- 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/reports/junit/.fa-cases.json +1 -0
- package/uscha-kit/reports/junit/.top-cases.json +1 -1
- package/uscha-kit/skills/uscha-devloop/SKILL.md +4 -1
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +265 -30
- package/uscha-kit/skills/uscha-devloop/uscha_top.py +176 -30
- package/uscha-kit/templates/scripts/uscha_progress.py +8 -2
- package/uscha-kit/uscha.config.json +1 -1
|
@@ -13,8 +13,8 @@ Truth-pass (INV-TOP-05): a field the engine emits as null renders as an em dash,
|
|
|
13
13
|
zero and never as a guess. In v0.1 that is ETA, every AGE, drift, and the trace column --
|
|
14
14
|
each with its deferred wiring recorded in ADR-035.
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
M2 scope: the read-only BOARD plus the live feed and its mtime poll. VERDICTS mode (M3) is
|
|
17
|
+
not wired; the pane that will hold it is labelled as such rather than faked.
|
|
18
18
|
|
|
19
19
|
Stdlib only. Python 3.8+. Runnable directly or via `python -m uscha_top`.
|
|
20
20
|
"""
|
|
@@ -25,6 +25,7 @@ import os
|
|
|
25
25
|
import shutil
|
|
26
26
|
import subprocess
|
|
27
27
|
import sys
|
|
28
|
+
import time
|
|
28
29
|
|
|
29
30
|
DEFAULT_LEDGER = "QA-LEDGER.json"
|
|
30
31
|
FALLBACK_SIZE = (100, 32)
|
|
@@ -32,8 +33,9 @@ FALLBACK_SIZE = (100, 32)
|
|
|
32
33
|
# Lines the board always spends on chrome: the title, 3 rules, 4 KPI lines, the table
|
|
33
34
|
# header, the feed label and the key hint. Everything else is table rows + feed.
|
|
34
35
|
CHROME_LINES = 11
|
|
35
|
-
FEED_MAX =
|
|
36
|
+
FEED_MAX = 8 # = the engine's events_tail length; a short terminal shows fewer
|
|
36
37
|
BURNUP_MAX = 24
|
|
38
|
+
MIN_REFRESH = 0.5 # a poll faster than this is a busy loop, not a refresh
|
|
37
39
|
|
|
38
40
|
# ANSI SGR by obligation state. TRACED and TAGGED deliberately share the UNMEASURED gray:
|
|
39
41
|
# the v0.1 engine has no source for either rung (ADR-032), so they must read as "not
|
|
@@ -52,6 +54,19 @@ MID = "·"
|
|
|
52
54
|
RULE = "─"
|
|
53
55
|
BLOCKS = "▁▂▃▄▅▆▇█"
|
|
54
56
|
|
|
57
|
+
# Feed levels: one letter and one colour each. The LETTER carries the level on the plain
|
|
58
|
+
# path (golden frames, pipes, CI) and the colour only decorates that same letter on a real
|
|
59
|
+
# terminal -- so both paths have identical geometry and a snapshot compares text, never
|
|
60
|
+
# terminal control codes. `info` is deliberately uncoloured: it is the level an unclassified
|
|
61
|
+
# step falls back to, and it must not look like a verdict.
|
|
62
|
+
FEED_LEVELS = {
|
|
63
|
+
"pass": ("P", "32"),
|
|
64
|
+
"fail": ("F", "31"),
|
|
65
|
+
"human": ("H", "33"),
|
|
66
|
+
"unmeasured": ("U", "90"),
|
|
67
|
+
"info": ("I", ""),
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
# What the reader is expected to DO about a row. Presentation, not a KPI: no number here.
|
|
56
71
|
ACTIONS = {
|
|
57
72
|
"MEASURED_PASS": DASH,
|
|
@@ -121,12 +136,16 @@ def _burnup_line(burnup, cols):
|
|
|
121
136
|
def _spec_pin_text(spec_pin):
|
|
122
137
|
"""git HEAD, labelled for what it is. There is no pinned-spec concept in the engine yet
|
|
123
138
|
(ADR-035/4): an unverified sha must SAY it is unverified, and a non-git tree shows the
|
|
124
|
-
em dash rather than a fabricated pin (AC-T-06, INV-TOP-05).
|
|
139
|
+
em dash rather than a fabricated pin (AC-T-06, INV-TOP-05).
|
|
140
|
+
|
|
141
|
+
The sha is state-supplied text like any other, so it goes through `_safe`: it shares a
|
|
142
|
+
line with no colour of its own, but a frozen state carrying an escape here would put one
|
|
143
|
+
in the header, and the header is the one line every frame has."""
|
|
125
144
|
if not spec_pin or not spec_pin.get("sha"):
|
|
126
145
|
return "spec_pin " + DASH
|
|
127
146
|
mark = ("clean-room verified" if spec_pin.get("clean_room_verified")
|
|
128
147
|
else "not clean-room verified")
|
|
129
|
-
return "spec_pin %s (%s)" % (spec_pin["sha"], mark)
|
|
148
|
+
return "spec_pin %s (%s)" % (_safe(spec_pin["sha"]), mark)
|
|
130
149
|
|
|
131
150
|
|
|
132
151
|
def _cases_text(ob):
|
|
@@ -139,11 +158,30 @@ def _cases_text(ob):
|
|
|
139
158
|
def _row(ob, selected):
|
|
140
159
|
gutter = "> " if selected else " "
|
|
141
160
|
return "%s%-8s%-9s%-15s%7s%5s %s" % (
|
|
142
|
-
gutter,
|
|
143
|
-
|
|
161
|
+
gutter, _safe(ob.get("id") or "?")[:8], _safe(ob.get("gate") or DASH)[:8],
|
|
162
|
+
_safe(ob.get("state") or "?")[:14], _cases_text(ob),
|
|
144
163
|
_num(ob.get("age_hours")), ACTIONS.get(ob.get("state"), DASH))
|
|
145
164
|
|
|
146
165
|
|
|
166
|
+
def _safe(text):
|
|
167
|
+
"""No control character reaches the terminal through the feed. The engine already
|
|
168
|
+
strips them where the text is derived (`_top_event_text`); this is the second guard on
|
|
169
|
+
the same surface, because the renderer also accepts a frozen state file a human wrote,
|
|
170
|
+
and one ESC in it would be a control sequence the board obeys instead of prints."""
|
|
171
|
+
return "".join(c for c in str(text or "") if ord(c) >= 32 and ord(c) != 127)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _feed_line(ev, cols, plain):
|
|
175
|
+
"""`HH:MM:SS L text` -- the level letter is the level, the colour only decorates it,
|
|
176
|
+
so the plain frame carries exactly the same information as the coloured one."""
|
|
177
|
+
letter, sgr = FEED_LEVELS.get(ev.get("level"), FEED_LEVELS["info"])
|
|
178
|
+
line = _fit(" %s %s %s" % (_safe(ev.get("ts")) or DASH, letter,
|
|
179
|
+
_safe(ev.get("text"))), cols)
|
|
180
|
+
if plain or not sgr:
|
|
181
|
+
return line
|
|
182
|
+
return line.replace(" %s " % letter, " \x1b[%sm%s%s " % (sgr, letter, RESET), 1)
|
|
183
|
+
|
|
184
|
+
|
|
147
185
|
def _colorize(line, state):
|
|
148
186
|
code = PALETTE.get(state)
|
|
149
187
|
if not code or state not in line:
|
|
@@ -167,9 +205,14 @@ def render(state, size, sel=0, plain=True):
|
|
|
167
205
|
debtors = state.get("debtors") or {}
|
|
168
206
|
honesty = state.get("honesty") or {}
|
|
169
207
|
|
|
208
|
+
# every string the STATE supplies goes through _safe on its way into a line (project,
|
|
209
|
+
# spec_pin, the row cells, the feed): after that the only escapes in a frame are the
|
|
210
|
+
# ones this renderer put there, which is what lets the final width pass leave coloured
|
|
211
|
+
# lines alone without a state file being able to smuggle one in (or widen a line).
|
|
170
212
|
out = []
|
|
171
|
-
out.append(_spread("uscha top %s %s"
|
|
172
|
-
|
|
213
|
+
out.append(_spread("uscha top %s %s"
|
|
214
|
+
% (MID, _safe(state.get("project")) or "(unnamed project)"),
|
|
215
|
+
"step #%s" % _safe(_num(state.get("step"))), cols))
|
|
173
216
|
out.append(RULE * cols)
|
|
174
217
|
out.append(_pct_line(terminado))
|
|
175
218
|
out.append("machine owes %s %s you owe %s %s untagged %s %s ETA %s"
|
|
@@ -177,9 +220,12 @@ def render(state, size, sel=0, plain=True):
|
|
|
177
220
|
_num(debtors.get("untagged")), MID, _num(state.get("eta_min"))))
|
|
178
221
|
# honesty travels BESIDE done on purpose (INV-TOP-04): a thin denominator has to be
|
|
179
222
|
# visible at the same glance as the number it flatters.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
223
|
+
# fitted HERE, at construction, not only by the pass at the end: this line carries the
|
|
224
|
+
# longest state-supplied string of the header, and the end pass skips coloured lines.
|
|
225
|
+
out.append(_fit("honesty %s/%s (%s%%) measured %s %s"
|
|
226
|
+
% (_num(honesty.get("measured")), _num(honesty.get("total")),
|
|
227
|
+
_num(honesty.get("pct")), MID,
|
|
228
|
+
_spec_pin_text(state.get("spec_pin"))), cols))
|
|
183
229
|
out.append(_burnup_line(state.get("burnup"), cols))
|
|
184
230
|
out.append(RULE * cols)
|
|
185
231
|
out.append(" %-8s%-9s%-15s%7s%5s %s"
|
|
@@ -214,17 +260,31 @@ def render(state, size, sel=0, plain=True):
|
|
|
214
260
|
pad = avail - len(table[:max(1, table_n)]) - feed_n
|
|
215
261
|
out.extend([""] * max(0, pad))
|
|
216
262
|
out.append(RULE * cols)
|
|
217
|
-
events = state.get("events_tail") or []
|
|
218
|
-
|
|
263
|
+
events = [e for e in (state.get("events_tail") or []) if isinstance(e, dict)]
|
|
264
|
+
shown = events[:feed_n]
|
|
265
|
+
if not events:
|
|
266
|
+
# honest empty label: a ledger with no steps has nothing to feed, and saying so is
|
|
267
|
+
# not the same statement as an idle feed with the lines scrolled away (INV-TOP-05).
|
|
268
|
+
out.append("feed %s no ledger step recorded yet (nothing to show)" % MID)
|
|
269
|
+
elif not shown:
|
|
270
|
+
# the board is served first (AC-T-21), so at the 80x24 floor with a long table the
|
|
271
|
+
# feed can lose every line. It says so; it does not pretend the ledger is quiet.
|
|
272
|
+
out.append("feed %s 0/%d %s no room at this size (the board is served first)"
|
|
273
|
+
% (MID, len(events), MID))
|
|
274
|
+
else:
|
|
275
|
+
# `3/8` says out loud that the pane is showing three of the eight steps the engine
|
|
276
|
+
# sent: a feed that silently drops lines is a feed that can hide the red one.
|
|
277
|
+
out.append("feed %s %d/%d %s newest first %s P/F/H/U/I = pass/fail/human/"
|
|
278
|
+
"unmeasured/info" % (MID, len(shown), len(events), MID, MID))
|
|
219
279
|
for i in range(feed_n):
|
|
220
|
-
if i < len(
|
|
221
|
-
ev = events[i]
|
|
222
|
-
out.append(_fit(" %s %s" % (ev.get("ts") or DASH, ev.get("text") or ""), cols))
|
|
223
|
-
else:
|
|
224
|
-
out.append("")
|
|
280
|
+
out.append(_feed_line(shown[i], cols, plain) if i < len(shown) else "")
|
|
225
281
|
out.append("[j/k] move %s [r] reload %s [q] quit %s [v] verdicts (M3) %s "
|
|
226
282
|
"[d]/[o] phase 2" % (MID, MID, MID, MID))
|
|
227
|
-
|
|
283
|
+
# a coloured line was already fitted BEFORE its escape bytes went in (table rows and
|
|
284
|
+
# feed lines both), and re-fitting it here would count those bytes as visible width --
|
|
285
|
+
# cutting the coloured frame ~9 characters shorter than the plain one it is supposed to
|
|
286
|
+
# match. Fit only what carries no escapes; the golden frames are that path exactly.
|
|
287
|
+
out = [line if "\x1b" in line else _fit(line, cols) for line in out]
|
|
228
288
|
# exactly `rows` lines: a frame that drifts in height is a frame no snapshot can pin
|
|
229
289
|
out = out[:rows] + [""] * max(0, rows - len(out))
|
|
230
290
|
return out
|
|
@@ -313,6 +373,63 @@ def read_key():
|
|
|
313
373
|
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
314
374
|
|
|
315
375
|
|
|
376
|
+
def wait_key(timeout):
|
|
377
|
+
"""One keypress, or "" when `timeout` seconds pass first. This is what makes the poll
|
|
378
|
+
possible without a busy loop AND without a key that waits for the next tick to be seen:
|
|
379
|
+
POSIX blocks in `select` (raw mode held for the whole window, so a single byte is
|
|
380
|
+
readable the instant it arrives), Windows walks `msvcrt.kbhit` in short slices."""
|
|
381
|
+
if os.name == "nt":
|
|
382
|
+
import msvcrt
|
|
383
|
+
deadline = time.time() + max(0.0, timeout)
|
|
384
|
+
while True:
|
|
385
|
+
if msvcrt.kbhit():
|
|
386
|
+
return read_key()
|
|
387
|
+
if time.time() >= deadline:
|
|
388
|
+
return ""
|
|
389
|
+
time.sleep(0.03)
|
|
390
|
+
import select
|
|
391
|
+
import termios
|
|
392
|
+
import tty
|
|
393
|
+
fd = sys.stdin.fileno()
|
|
394
|
+
try:
|
|
395
|
+
saved = termios.tcgetattr(fd)
|
|
396
|
+
except Exception:
|
|
397
|
+
return "" # no terminal to read: never block
|
|
398
|
+
try:
|
|
399
|
+
tty.setraw(fd)
|
|
400
|
+
ready, _, _ = select.select([sys.stdin], [], [], max(0.0, timeout))
|
|
401
|
+
return sys.stdin.read(1) if ready else ""
|
|
402
|
+
finally:
|
|
403
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _changed(paths, seen):
|
|
407
|
+
"""(changed?, new snapshot) for a set of files, by (mtime, size).
|
|
408
|
+
|
|
409
|
+
The whole of the M2 poll: no server, no watcher, no thread (ADR-031). Kept as a small
|
|
410
|
+
pure-ish function on purpose -- it is the piece the suite can actually drive (AC-T-12),
|
|
411
|
+
while a real TTY session is not. A path that cannot be stat'ed records None instead of
|
|
412
|
+
raising: a ledger deleted under the app is a CHANGE, not a crash."""
|
|
413
|
+
now = {}
|
|
414
|
+
for path in paths or []:
|
|
415
|
+
if not path:
|
|
416
|
+
continue
|
|
417
|
+
try:
|
|
418
|
+
st = os.stat(path)
|
|
419
|
+
now[path] = (st.st_mtime, st.st_size)
|
|
420
|
+
except OSError:
|
|
421
|
+
now[path] = None
|
|
422
|
+
return now != (seen if seen is not None else {}), now
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def watch_paths(args):
|
|
426
|
+
"""What the poll watches: the frozen state file when one is given, otherwise the ledger
|
|
427
|
+
the engine reads. Nothing else -- `discovery/CANDIDATE-DELTA.json` is NOT watched in
|
|
428
|
+
v0.1 (the state carries no path to it), so a `discover` run that leaves the ledger
|
|
429
|
+
untouched is seen on the next `r`, not on the next tick. Under-claim, then wire."""
|
|
430
|
+
return [args.state] if getattr(args, "state", None) else [getattr(args, "ledger", None)]
|
|
431
|
+
|
|
432
|
+
|
|
316
433
|
def dispatch(key, sel, count):
|
|
317
434
|
"""Key -> (new selection, quit?, reload?). Pure, so the keymap is testable without a
|
|
318
435
|
terminal: the driver below is not what is under test, this dispatch is (ADR-034)."""
|
|
@@ -339,20 +456,48 @@ def _print_frame(lines):
|
|
|
339
456
|
sys.stdout.flush()
|
|
340
457
|
|
|
341
458
|
|
|
459
|
+
def _reload(state, args):
|
|
460
|
+
"""Re-read, or keep what is on screen. A poll that catches the ledger MID-WRITE reads a
|
|
461
|
+
truncated file; the last good board plus a retry next tick is honest, a traceback over
|
|
462
|
+
a working terminal is not."""
|
|
463
|
+
try:
|
|
464
|
+
return load_state(args.state, args.ledger)
|
|
465
|
+
except (OSError, ValueError, RuntimeError):
|
|
466
|
+
return state
|
|
467
|
+
|
|
468
|
+
|
|
342
469
|
def _loop(state, args):
|
|
343
470
|
sel = 0
|
|
471
|
+
interval = max(MIN_REFRESH, float(args.refresh or 0))
|
|
472
|
+
paths = watch_paths(args)
|
|
473
|
+
_seed, seen = _changed(paths, {}) # the first frame is already current
|
|
474
|
+
dirty = True
|
|
344
475
|
sys.stdout.write("\x1b[?25l")
|
|
345
476
|
try:
|
|
346
477
|
while True:
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
478
|
+
if dirty:
|
|
479
|
+
frame = render(state, terminal_size(args.cols, args.rows),
|
|
480
|
+
sel=sel, plain=False)
|
|
481
|
+
sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(frame))
|
|
482
|
+
sys.stdout.flush()
|
|
483
|
+
dirty = False
|
|
484
|
+
# one wait serves both jobs: a key answers immediately, and the deadline is the
|
|
485
|
+
# `--refresh` tick that re-reads only when a watched file actually moved.
|
|
486
|
+
key = wait_key(interval)
|
|
487
|
+
if key:
|
|
488
|
+
sel, quit_now, reload_now = dispatch(
|
|
489
|
+
key, sel, len(state.get("obligations") or []))
|
|
490
|
+
if quit_now:
|
|
491
|
+
return 0
|
|
492
|
+
if reload_now:
|
|
493
|
+
state = _reload(state, args)
|
|
494
|
+
_fresh, seen = _changed(paths, seen)
|
|
495
|
+
dirty = True
|
|
496
|
+
continue
|
|
497
|
+
moved, seen = _changed(paths, seen)
|
|
498
|
+
if moved:
|
|
499
|
+
state = _reload(state, args)
|
|
500
|
+
dirty = True
|
|
356
501
|
except KeyboardInterrupt:
|
|
357
502
|
return 0
|
|
358
503
|
finally:
|
|
@@ -374,7 +519,8 @@ def build_parser():
|
|
|
374
519
|
parser.add_argument("--plain", action="store_true",
|
|
375
520
|
help="never emit escape sequences")
|
|
376
521
|
parser.add_argument("--refresh", type=float, default=2.0,
|
|
377
|
-
help="
|
|
522
|
+
help="seconds between mtime polls of the ledger (default: 2, "
|
|
523
|
+
"floor %.1f); `r` still forces a re-read" % MIN_REFRESH)
|
|
378
524
|
parser.add_argument("--cols", type=int, default=None)
|
|
379
525
|
parser.add_argument("--rows", type=int, default=None)
|
|
380
526
|
return parser
|
|
@@ -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.88.0",
|
|
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, 52 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.88.0 <!-- 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.88.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"AC-FA-01": true, "AC-FA-02": true, "AC-FA-03": null, "AC-FA-04": true, "AC-FA-05": true, "AC-FA-06": true}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"AC-T-01": true, "AC-T-02": true, "AC-T-03": true, "AC-T-10": true, "AC-T-04": true, "AC-T-05": true, "AC-T-06": true, "AC-T-09": true, "AC-T-24": true, "reg-quarantine-obs-null-on-measured": true, "reg-spec-pin-null-outside-worktree": true, "reg-unreachable-repo-named-not-silent": true, "AC-T-19": true, "reg-empty-project-honest": true, "AC-T-23": true, "AC-T-21": true, "AC-T-08": true, "AC-T-07": true, "AC-T-18": true, "AC-T-20": true, "AC-T-22": true, "reg-ledger-not-found": true}
|
|
1
|
+
{"AC-T-01": true, "AC-T-02": true, "AC-T-03": true, "AC-T-10": true, "AC-T-04": true, "AC-T-05": true, "AC-T-06": true, "AC-T-09": true, "AC-T-24": true, "reg-quarantine-obs-null-on-measured": true, "reg-spec-pin-null-outside-worktree": true, "reg-unreachable-repo-named-not-silent": true, "reg-top-events-malformed-fields-degrade": true, "AC-T-11": true, "AC-T-19": true, "reg-empty-project-honest": true, "AC-T-23": true, "AC-T-21": true, "AC-T-08": true, "AC-T-07": true, "AC-T-18": true, "AC-T-20": true, "AC-T-22": true, "reg-ledger-not-found": true, "AC-T-12": true, "reg-top-render-state-text-cannot-widen-or-escape": true}
|
|
@@ -535,7 +535,10 @@ once logged it caps readiness ≤65 and blocks convergence until resolved with
|
|
|
535
535
|
criterion carries a stable ID: `- [ ] AC-01 — when X then Y`. A criterion counts as
|
|
536
536
|
CLOSED only when ≥1 GREEN testcase whose name carries the tag (`test_ac1_x`,
|
|
537
537
|
`testAC01X`, `"AC-01: ..."` — IDs normalize by number, `AC-01 == AC_1 == ac1`) exists
|
|
538
|
-
in the ingested JUnit reports AND no tagged testcase is red.
|
|
538
|
+
in the ingested JUnit reports AND no tagged testcase is red. Since kit 1.87.0 (ADR-036)
|
|
539
|
+
a FAMILY prefix is read the same way: `- [ ] AC-BC-07 — ...` closes on `AC-BC-07_x`,
|
|
540
|
+
`test_ac_bc_7_y` or `AC_BC_7` (normalized to `AC-BC-7`; the family needs a separator on
|
|
541
|
+
both sides — camelCase `testACBC07` is NOT a tag, and `AC-7-x` is still the bare `AC-7`). The checkbox is the
|
|
539
542
|
NARRATIVE; the testcase is the FACT — a checked box without a green tagged test shows
|
|
540
543
|
up as `narrated_only` and does NOT close (measured beats narrated, per criterion).
|
|
541
544
|
A JUnit report older than the repo's source code is treated as STALE (the code changed
|