@techierathore/techieflow 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
  2. package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
  3. package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
  4. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
  5. package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
  6. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
  7. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +4 -4
  8. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  9. package/.claude/commands/TechieFlow/tasks/fix-issues.md +1 -0
  10. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  11. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  12. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  13. package/.opencode/plugin/techieflow.js +15 -7
  14. package/.tfcore/agents/flow-master.md +2 -0
  15. package/.tfcore/agents/verifier.md +2 -2
  16. package/.tfcore/core-config.yaml +2 -1
  17. package/.tfcore/hooks/guard-db.sh +106 -9
  18. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  19. package/.tfcore/hooks/yolo-allow.sh +27 -0
  20. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  21. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  22. package/.tfcore/tasks/_develop-mode.md +34 -0
  23. package/.tfcore/tasks/_yolo-mode.md +2 -2
  24. package/.tfcore/tasks/amend-docs.md +1 -1
  25. package/.tfcore/tasks/day1-brownfield.md +2 -2
  26. package/.tfcore/tasks/day1-greenfield.md +4 -4
  27. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  28. package/.tfcore/tasks/fix-issues.md +1 -0
  29. package/.tfcore/tasks/mockups.md +1 -1
  30. package/.tfcore/tasks/triage-and-fix.md +2 -2
  31. package/.tfcore/tasks/verify-phase.md +12 -11
  32. package/.tfcore/telemetry/SCHEMA.md +6 -2
  33. package/.tfcore/telemetry/tf-metrics.sh +19 -0
  34. package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
  35. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  36. package/.tfcore/templates/v4custom/app-brd-tmpl.md +2 -1
  37. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  38. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  39. package/.tfcore/templates/v4custom/html-render-shell.md +2 -0
  40. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  41. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  42. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  43. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  44. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  45. package/.tfcore/utils/tf-appium.mjs +179 -0
  46. package/.tfcore/utils/tf-build-list.py +16 -1
  47. package/.tfcore/utils/tf-build.sh +75 -0
  48. package/.tfcore/utils/tf-checklist-edit.py +21 -3
  49. package/.tfcore/utils/tf-day1-files.py +55 -28
  50. package/.tfcore/utils/tf-develop.sh +264 -0
  51. package/.tfcore/utils/tf-devguide-list.py +6 -2
  52. package/.tfcore/utils/tf-doc-check.py +83 -21
  53. package/.tfcore/utils/tf-doc-tests.sh +40 -3
  54. package/.tfcore/utils/tf-emit.sh +40 -10
  55. package/.tfcore/utils/tf-fix-close.sh +13 -7
  56. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  57. package/.tfcore/utils/tf-menus.mjs +401 -0
  58. package/.tfcore/utils/tf-mockup-parity.mjs +355 -27
  59. package/.tfcore/utils/tf-mockup-parity.sh +14 -3
  60. package/.tfcore/utils/tf-phase.sh +24 -4
  61. package/.tfcore/utils/tf-render-html.py +19 -3
  62. package/.tfcore/utils/tf-req-ids.py +36 -0
  63. package/.tfcore/utils/tf-stack-check.sh +3 -0
  64. package/.tfcore/utils/tf-status-facts.py +90 -15
  65. package/.tfcore/utils/tf-theme.mjs +189 -0
  66. package/.tfcore/utils/tf-triage.py +12 -1
  67. package/.tfcore/utils/tf-verify-boot.sh +274 -19
  68. package/.tfcore/utils/tf-verify-emit.sh +37 -17
  69. package/.tfcore/utils/tf-verify-list.py +219 -20
  70. package/.tfcore/utils/tf-verify-list.sh +3 -1
  71. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  72. package/.tfcore/utils/tf-verify-screens.mjs +178 -20
  73. package/.tfcore/utils/tf-verify-screens.sh +17 -4
  74. package/.tfcore/utils/tf-verify-tests.sh +107 -17
  75. package/.tfcore/utils/tf-verify-verdict.py +118 -15
  76. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  77. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  78. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  79. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  80. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  81. package/.tfcore/utils/tf-yolo.sh +31 -3
  82. package/.tfcore/utils/tf_feedback.py +79 -16
  83. package/.tfcore/utils/tf_stack_check.py +93 -0
  84. package/README.md +2 -2
  85. package/opencode.jsonc +6 -2
  86. package/package.json +1 -1
  87. package/scripts/install.mjs +13 -2
  88. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  89. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  90. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  91. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  92. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  93. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  94. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  95. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  96. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  97. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  98. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -42,12 +42,25 @@ case "${1:-}" in
42
42
  # the outer command as "outer", written FIRST so a greedy `.*"started":"…"` read still takes this
43
43
  # command's own start. tf-verify-emit.sh and tf-fix-close.sh read it to tell the two starts
44
44
  # apart (TF-052: the inline verify took the fix's start and its record swallowed the fix's).
45
- OUTER=""
45
+ OUTER=""; CHAINED=""
46
46
  if [[ -f "$FILE" && -z "$(find "$FILE" -mmin +1440 2>/dev/null)" ]]; then
47
47
  OC="$(sed -n 's/.*"cmd":"\([^"]*\)".*/\1/p' "$FILE")"; OA="$(sed -n 's/.*"app":"\([^"]*\)".*/\1/p' "$FILE")"
48
48
  OS="$(sed -n 's/.*"started":"\([^"]*\)".*/\1/p' "$FILE")"
49
49
  [[ -n "$OC" && -n "$OS" && "$OC" != "$CMD" && "$OC" != "goal" && "$OS" != "$NOW" ]] \
50
50
  && OUTER="\"outer\":{\"cmd\":\"$OC\",\"app\":\"$OA\",\"started\":\"$OS\"},"
51
+ # A command the task files run inside another one keeps the outer command's document baseline
52
+ # (TrBlazeUI TF-002: *triage-and-fix started verify-phase and metrics-report, each wrote a new
53
+ # baseline, and the rows the triage had just added had their findings relabelled OLD). The pairs
54
+ # are the chains the task files name: build-phase, fix-issues and triage-and-fix run verify-phase;
55
+ # triage-and-fix runs metrics-report. The marker is never ended, so a standalone verify after a
56
+ # fix also keeps the fix's baseline: that can only make the gate stricter, never hide a finding.
57
+ OOC="$(sed -n 's/^{"outer":{"cmd":"\([^"]*\)".*/\1/p' "$FILE")"
58
+ case "$CMD" in
59
+ verify-phase|metrics-report)
60
+ for c in "$OC" "$OOC"; do
61
+ case "$c" in build-phase|fix-issues|triage-and-fix) CHAINED="$c"; break ;; esac
62
+ done ;;
63
+ esac
51
64
  fi
52
65
  printf '{%s"cmd":"%s","app":"%s","started":"%s"}\n' "$OUTER" "$CMD" "$APP" "$NOW" > "$FILE"
53
66
  echo "$NOW"
@@ -56,10 +69,17 @@ case "${1:-}" in
56
69
  # status file already carry when the command starts are recorded, so the gate and the Stop
57
70
  # hook block only on findings this command introduces (Schemas §7.1 decision 8: old projects
58
71
  # are repaired through *amend-docs, never by whichever command happens to run next).
59
- if [[ -x "$ROOT/.tfcore/utils/tf-doc-check.sh" || -f "$ROOT/.tfcore/utils/tf-doc-check.sh" ]]; then
72
+ if [[ -n "$CHAINED" ]]; then
73
+ echo "tf-phase: $CMD runs inside $CHAINED — keeping its document baseline (findings since $CHAINED started stay FAIL)" >&2
74
+ elif [[ -x "$ROOT/.tfcore/utils/tf-doc-check.sh" || -f "$ROOT/.tfcore/utils/tf-doc-check.sh" ]]; then
75
+ # Every file `tf-doc-check.sh --app` reads, not only the checklists and the status file: *amend-docs
76
+ # and day-1 close on `--app`, so the BRD's, the Architecture's and the mockups' old findings read
77
+ # as new and blocked the run (Lekhak TF-023, 131 of them). A baseline only relabels what was
78
+ # already there, so a finding the command introduces still FAILs.
60
79
  docs=(); for f in "$ROOT"/docs/*-Checklist.md "$ROOT"/PROJECT-STATUS.md; do [[ -f "$f" ]] && docs+=("$f"); done
61
- if [[ ${#docs[@]} -gt 0 ]]; then
62
- ( cd "$ROOT" && bash .tfcore/utils/tf-doc-check.sh --root "$ROOT" --baseline-write "${docs[@]}" 2>/dev/null | tail -1 >&2 ) || true
80
+ appargs=(); [[ -n "$APP" && -d "$ROOT/docs" ]] && appargs=(--app "$APP")
81
+ if [[ ${#docs[@]} -gt 0 || ${#appargs[@]} -gt 0 ]]; then
82
+ ( cd "$ROOT" && bash .tfcore/utils/tf-doc-check.sh --root "$ROOT" --baseline-write ${appargs[@]+"${appargs[@]}"} ${docs[@]+"${docs[@]}"} 2>/dev/null | tail -1 >&2 ) || true
63
83
  fi
64
84
  fi
65
85
  # The framework's own scripts, against THIS project's real files, before the command
@@ -101,6 +101,20 @@ class Slugger(object):
101
101
  self.seen[s] = c
102
102
  return s if c == 1 else "%s-%d" % (s, c)
103
103
 
104
+ def github(self, text):
105
+ """The id GitHub gives the same heading, which a markdown table of contents links to: digits
106
+ kept, punctuation dropped, spaces to hyphens, a repeat suffixed -1, -2 (AppManager TF-029:
107
+ `### 3.7 Group Service (GroupSvc)` is #37-group-service-groupsvc there, #group-service-groupsvc
108
+ here, so every numbered TOC link in the rendered guide went nowhere)."""
109
+ if not hasattr(self, "gh_seen"):
110
+ self.gh_seen = {}
111
+ s = re.sub(r"<[^>]+>", "", text)
112
+ s = re.sub(r"[*`~]", "", s).strip().lower()
113
+ s = re.sub(r"[^\w\- ]", "", s, flags=re.UNICODE).replace(" ", "-")
114
+ c = self.gh_seen.get(s, 0)
115
+ self.gh_seen[s] = c + 1
116
+ return s if c == 0 else "%s-%d" % (s, c)
117
+
104
118
 
105
119
  # ----------------------------------------------------------------------------
106
120
  # Inline conversion
@@ -205,11 +219,13 @@ class Renderer(object):
205
219
 
206
220
  def heading(self, level, text):
207
221
  slug = self.sl.slug(text)
222
+ gh = self.sl.github(text) # every level, so its repeat count matches GitHub's
208
223
  if level in (2, 3, 4):
209
- self.emit('<h%d id="%s">%s<a class="anchor-link" href="#%s">#</a></h%d>'
210
- % (level, slug, inline(text), slug, level))
224
+ alias = '<a id="%s"></a>' % gh if gh and gh != slug else ""
225
+ self.emit('<h%d id="%s">%s%s<a class="anchor-link" href="#%s">#</a></h%d>'
226
+ % (level, slug, alias, inline(text), slug, level))
211
227
  else:
212
- self.emit("<h%d>%s</h%d>" % (level, inline(text), level))
228
+ self.emit("<h%d%s>%s</h%d>" % (level, ' id="%s"' % gh if gh else "", inline(text), level))
213
229
  if level == 2:
214
230
  self.h2 += 1
215
231
  if level in (2, 3):
@@ -0,0 +1,36 @@
1
+ """tf-req-ids.py — which rows a test's name claims (Chatur TF-012). Imported by tf-verify-tests.sh.
2
+
3
+ A test proves the ids its name STARTS with, nothing else. An xUnit theory reported as
4
+ `REQ-UI-048 requirement ids are found in test names(aName: "REQ-FN-053 …")` passed REQ-FN-053 and
5
+ REQ-FN-055, which no test had ever touched. The ids are read from the leading run of REQ tokens
6
+ (separated by spaces, commas, slashes, "and" …, after at most one short capital tag such as FIX or
7
+ UAT), before any argument list. An id named later in the name is the test's subject matter, not its
8
+ claim. A theory whose argument is NAMED for the row it proves (`row: "REQ-RAG-125"`, also req, reqId,
9
+ requirement) claims that row: TechieRag's per-row cases.
10
+ """
11
+ import re
12
+
13
+ ID = re.compile(r"(REQ-(?:UI|FN|NFR|RAG)-\d{3})", re.I)
14
+ LEAD_SEP = re.compile(r"(?:[\s,;/&+|·:\[\]–—-]|\band\b)*", re.I)
15
+ TAG = re.compile(r"\s*\[?[A-Z][A-Z0-9]{1,5}\]?:?\s+(?=REQ-)")
16
+ ROW_ARG = re.compile(r"\b(?:row|req|reqid|req_id|requirement)\s*[:=]\s*[\"']?(REQ-(?:UI|FN|NFR|RAG)-\d{3})\b", re.I)
17
+
18
+
19
+ def ids_of(name):
20
+ """the ids one name claims, in order"""
21
+ name = str(name or "")
22
+ head, out = name.split("(", 1)[0], [x.upper() for x in ROW_ARG.findall(name)]
23
+ t = TAG.match(head)
24
+ p = t.end() if t else 0
25
+ while True:
26
+ p = LEAD_SEP.match(head, p).end()
27
+ m = ID.match(head, p)
28
+ if not m:
29
+ return out
30
+ out.append(m.group(1).upper())
31
+ p = m.end()
32
+
33
+
34
+ def mentioned(name):
35
+ """every id the name carries anywhere"""
36
+ return {x.upper() for x in ID.findall(str(name or ""))}
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # tf-stack-check.sh — does the code use the packages the Architecture requires? See tf_stack_check.py.
3
+ exec python3 "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/tf_stack_check.py" "$@"
@@ -14,7 +14,10 @@ into the template (.tfcore/templates/v4custom/app-project-status-tmpl.md):
14
14
  - the Open requirements section (counts by status, at most ten named rows)
15
15
  - the Verification log table with its new row (last five kept)
16
16
  - the Library feedback summary lines
17
- - the Next command to run, in both harness forms, and why
17
+ - the Next command to run, in both harness forms, and why. On a project with more than one
18
+ phase a verify names its phase (`*verify ui <App> --phase 3`), and built rows waiting in
19
+ another phase's checklist are found too: `--phase N` for one, `--phase all` for several
20
+ (Sevak TF-005). A build of this phase still comes first.
18
21
 
19
22
  Writes nothing. The agent writes PROJECT-STATUS.md with the harness Write tool.
20
23
  Python 3 standard library only. Exit 0 printed, 2 could not run.
@@ -170,8 +173,19 @@ def existing_log_rows(status_text):
170
173
  return rows
171
174
 
172
175
 
173
- def handoff_ran(log_rows):
174
- return any("handoff" in r.lower() for r in log_rows)
176
+ def handoff_ran(log_rows, app="", phase=1):
177
+ """A handoff of THIS phase: a log row naming handoff whose status-table cell names this phase's
178
+ checklist. Phase 2's handoff counted for phase 3, so a newly built phase read "handoff done" and
179
+ sent the owner to UAT with guides that did not describe it (TechieRag TF-003). A row naming no
180
+ checklist (an older log) counts in phase 1 only, where nothing else could be meant."""
181
+ own = f"{phase_prefix(app, phase)}Checklist.md" if app else ""
182
+ for r in log_rows:
183
+ if "handoff" not in r.lower():
184
+ continue
185
+ named = re.findall(r"[\w.-]+-Checklist\.md", r)
186
+ if (own in named) if named and own else (phase <= 1):
187
+ return True
188
+ return False
175
189
 
176
190
 
177
191
  def feedback_lines(root, app):
@@ -193,6 +207,13 @@ def config_phase(root):
193
207
  return 1
194
208
 
195
209
 
210
+ def config_library(root):
211
+ """metrics.project_type: library (or appKind: library): the documents ship inside the package."""
212
+ p = os.path.join(root, ".tfcore", "core-config.yaml")
213
+ t = read(p) if os.path.isfile(p) else ""
214
+ return bool(re.search(r"(?m)^\s+project_type:\s*['\"]?lib", t) or re.search(r"(?m)^appKind:\s*['\"]?lib", t))
215
+
216
+
196
217
  def phase_prefix(app, phase):
197
218
  return f"{app}-" if phase <= 1 else f"{app}-P{phase}-"
198
219
 
@@ -222,9 +243,39 @@ def usage_guide(app):
222
243
  return vl.usage_guide(app)
223
244
 
224
245
 
225
- def next_command(app, rows, log_rows, phase=1, verdicts=None):
226
- """Returns (phase, qualifier, cc_line, oc_line, reason)."""
246
+ def to_verify(rows, verdicts):
247
+ """(waiting, unmeasured): built rows a verify should grade, and built rows the last verify could
248
+ not measure (every test skipped). Roadmap rows and rows carrying a defect are neither."""
249
+ built = [r for r in rows if not r.get("roadmap") and r["status"].lower() in BUILT and not has_defect(r)]
250
+ unmeasured = [r for r in built if verdicts.get(r["id"]) == "NOT-TESTED"]
251
+ return [r for r in built if r not in unmeasured], unmeasured
252
+
253
+
254
+ def scope_of(rows):
255
+ ui = [r for r in rows if r["id"].startswith("REQ-UI")]
256
+ return "ui" if len(ui) == len(rows) else ("functional" if not ui else "all")
257
+
258
+
259
+ def other_phases(root, app, phase_n, verdicts):
260
+ """{phase: rows waiting for a verify} for every other phase with a checklist (Sevak TF-005: phases 1
261
+ and 2 held 86 rows waiting while the status read only phase 3's checklist)."""
262
+ found = {}
263
+ for f in sorted(os.listdir(os.path.join(root, "docs"))) if os.path.isdir(os.path.join(root, "docs")) else []:
264
+ m = re.fullmatch(re.escape(app) + r"-(?:P(\d+)-)?Checklist\.md", f)
265
+ p = int(m.group(1) or 1) if m else 0
266
+ if not p or p == phase_n:
267
+ continue
268
+ waiting, _ = to_verify(checklist_rows(read(os.path.join(root, "docs", f))), verdicts)
269
+ if waiting:
270
+ found[p] = waiting
271
+ return dict(sorted(found.items()))
272
+
273
+
274
+ def next_command(app, rows, log_rows, phase=1, verdicts=None, library=False, multi=False):
275
+ """Returns (phase, qualifier, cc_line, oc_line, reason). multi: the project has more than one
276
+ phase, so a verify names the phase it grades."""
227
277
  verdicts = verdicts or {}
278
+ pflag = f" --phase {phase}" if multi else ""
228
279
 
229
280
  def ids(lst):
230
281
  return ", ".join(r["id"] for r in lst[:6]) + (" …" if len(lst) > 6 else "")
@@ -242,7 +293,8 @@ def next_command(app, rows, log_rows, phase=1, verdicts=None):
242
293
  now = [r for r in rows if not r.get("roadmap")]
243
294
  unbuilt = [r for r in now if r["status"].lower() not in TERMINAL | PASS_THROUGH | BUILT | OWNER]
244
295
  defects = [r for r in now if has_defect(r)]
245
- built = [r for r in now if r["status"].lower() in BUILT and r not in defects]
296
+ waiting, unmeasured = to_verify(now, verdicts)
297
+ built = waiting + unmeasured
246
298
  owner = [r for r in now if r["status"].lower() in OWNER]
247
299
  verified, counted, na = figures(rows)
248
300
  q = f"{verified} of {counted} verified" + (f", {na} not applicable" if na else "")
@@ -262,31 +314,34 @@ def next_command(app, rows, log_rows, phase=1, verdicts=None):
262
314
  # loop that looks like progress. Say what it actually needs: data, a changed
263
315
  # acceptance line, or the row marked N/A. Only the owner can choose. FR-69's sibling
264
316
  # case; the verdicts come from the ledger the verifier already writes.
265
- unmeasured = [r for r in built if verdicts.get(r["id"]) == "NOT-TESTED"]
266
- waiting = [r for r in built if r not in unmeasured]
267
317
  if unmeasured and not waiting:
268
318
  line = (f"(owner) {len(unmeasured)} row(s) cannot be measured here — create the data, "
269
319
  f"change the acceptance line, or mark the row N/A: {ids(unmeasured)}")
270
320
  return ("Verify", f"{len(unmeasured)} not measurable, {q}", line, line,
271
321
  f"every test carrying these ids was skipped, so another verify run changes nothing: {ids(unmeasured)}")
272
- ui = [r for r in waiting if r["id"].startswith("REQ-UI")]
273
- scope = "ui" if len(ui) == len(waiting) else ("functional" if not ui else "all")
322
+ scope = scope_of(waiting)
274
323
  reason = f"{len(waiting)} rows are built and not verified: {ids(waiting)}"
275
324
  if unmeasured:
276
325
  reason += (f"; {len(unmeasured)} more could not be measured (every test skipped) "
277
326
  f"and need the owner: {ids(unmeasured)}")
278
327
  return ("Verify", f"{len(waiting)} to verify, {q}",
279
- f"{CC}verifier *verify {scope} {app}", f"{OC}flow-verifier *verify {scope} {app}",
328
+ f"{CC}verifier *verify {scope} {app}{pflag}", f"{OC}flow-verifier *verify {scope} {app}{pflag}",
280
329
  reason)
281
330
  if owner:
282
- if handoff_ran(log_rows):
331
+ if handoff_ran(log_rows, app, phase):
283
332
  line = f"(owner-run) {usage_guide(app)} — {ids(owner)}"
284
333
  return ("UAT", f"{len(owner)} owner-run, {q}", line, line,
285
334
  f"{len(owner)} rows are Owner-UAT and handoff has run; the owner closes them")
286
335
  return ("Handoff", f"{len(owner)} owner-run, {q}",
287
336
  f"{CC}flow-master *handoff-phase {app}", f"{OC}flow-master *handoff-phase {app}",
288
337
  f"{len(owner)} rows are Owner-UAT and handoff has not run yet")
289
- if handoff_ran(log_rows):
338
+ if handoff_ran(log_rows, app, phase):
339
+ if library:
340
+ # a library's fix runs the handoff steps itself, before the gate, so the package ships with
341
+ # its documents; *handoff-phase after the release was the wrong order (TrBlazeUI TF-005)
342
+ line = "(owner) commit, then build and publish the package — its shipped documents are current; no agent command"
343
+ return ("Release", f"handoff done, {q}", line, line,
344
+ "every row in this phase's scope is terminal and the shipped documents were brought up to date")
290
345
  line = "(owner) set current_phase to Released after UAT — no agent command"
291
346
  return ("UAT", f"handoff done, {q}", line, line,
292
347
  "every row in this phase's scope is terminal and handoff has run; waiting on the owner")
@@ -320,12 +375,32 @@ def main(argv):
320
375
  st_text = read(st_path) if os.path.isfile(st_path) else ""
321
376
  log_rows = existing_log_rows(st_text)
322
377
  vdate, vresult = last_verify(root, app)
323
- phase, qual, cc, oc, reason = next_command(app, rows, log_rows, phase_n, ledger_verdicts(root))
378
+ verdicts = ledger_verdicts(root)
379
+ others = other_phases(root, app, phase_n, verdicts)
380
+ pname, ptotal = phase_row(root, app, phase_n)
381
+ multi = phase_n > 1 or (ptotal or 0) > 1 or bool(others)
382
+ phase, qual, cc, oc, reason = next_command(app, rows, log_rows, phase_n, verdicts, config_library(root), multi)
383
+ if others:
384
+ # A verify of another phase names that phase, or --phase all when several phases wait; a build
385
+ # of this phase still goes first.
386
+ n = sum(len(w) for w in others.values())
387
+ where = "; ".join(f"phase {p}: {len(w)} ({', '.join(r['id'] for r in w[:4])}{' …' if len(w) > 4 else ''})"
388
+ for p, w in others.items())
389
+ mine, _ = to_verify(rows, verdicts)
390
+ if phase == "Build":
391
+ reason += f"; after this build, {n} built rows of other phases wait for a verify — {where}"
392
+ else:
393
+ pick = list(others) + ([phase_n] if mine else [])
394
+ flag = f"--phase {pick[0]}" if len(pick) == 1 else "--phase all"
395
+ scope = scope_of([r for w in others.values() for r in w] + mine)
396
+ cc, oc = f"{CC}verifier *verify {scope} {app} {flag}", f"{OC}flow-verifier *verify {scope} {app} {flag}"
397
+ reason = (f"{n} rows of other phases are built and not verified — {where}"
398
+ + (f"; and in this phase: {reason}" if phase == "Verify" else f"; this phase alone would be: {qual} ({reason})"))
399
+ phase, qual = "Verify", f"{n + len(mine)} to verify across phases {', '.join(map(str, sorted(pick)))}, {qual}"
324
400
  roadmap = [r["id"] for r in rows if r["roadmap"]]
325
401
  if roadmap:
326
402
  reason += (f"; {len(roadmap)} roadmap row(s) are not in this phase's scope and are left for the owner "
327
403
  f"(*amend-docs): {', '.join(roadmap)}")
328
- pname, ptotal = phase_row(root, app, phase_n)
329
404
  if ptotal or phase_n > 1:
330
405
  tag = f"Phase {phase_n} of {ptotal or '?'}" + (f" ({pname})" if pname else "")
331
406
  phase = f"{tag} · {phase}"
@@ -0,0 +1,189 @@
1
+ // tf-theme.mjs — draw a page in its light or its dark theme, and measure what a reader sees in it.
2
+ // Shared by tf-verify-screens and tf-mockup-parity, as tf-login.mjs is (Lekhak TF-024).
3
+ //
4
+ // Why: every screen was graded in light mode only. Lekhak's owner then found 20 of 20 desktop admin
5
+ // screens unreadable in dark mode (text at 1.03 : 1, white panels inside a dark shell), all Verified.
6
+ //
7
+ // A theme is put on a page the two ways a stack listens for one, knowing nothing about the stack:
8
+ // - the browser's colour scheme (prefers-color-scheme), emulated;
9
+ // - the light/dark value of every data-*theme/mode/scheme attribute on <html> and <body>, and the
10
+ // light/dark mode classes there (dark, theme-dark …). A page that declares none is given
11
+ // data-theme and data-bs-theme; a page with no such class is given, for dark, the class `dark`
12
+ // (Tailwind's switch, and the one an app setting data-theme and the class together also reads).
13
+ // Each call returns a function that puts the page back the way it was: an attached desktop head is
14
+ // the viewer's app. A page whose painted surface does not change between the two has no dark theme;
15
+ // `surface` and `differs` tell. `audit` is the contrast rule the visual check applies.
16
+
17
+ export const THEMES = ['light', 'dark'];
18
+
19
+ // --themes light,dark | light | dark | auto (the default: light, and dark when the app has one)
20
+ export function parseThemes(v) {
21
+ const s = String(v || 'auto').toLowerCase().trim();
22
+ if (s === 'auto') return null;
23
+ const t = [...new Set(s.split(',').map((x) => x.trim()).filter((x) => THEMES.includes(x)))];
24
+ return t.length ? t : null;
25
+ }
26
+
27
+ const FORCE = (want) => {
28
+ const MODE = /^(?:(?:theme|mode|scheme|color)[-_])?(?:dark|light)(?:[-_](?:theme|mode|scheme))?$/i;
29
+ const other = want === 'dark' ? /light/ig : /dark/ig;
30
+ const before = [];
31
+ let declared = false, classed = false;
32
+ for (const [where, el] of [['html', document.documentElement], ['body', document.body]]) {
33
+ if (!el) continue;
34
+ for (const a of [...el.attributes]) {
35
+ if (!/^data-.*(theme|mode|scheme)/i.test(a.name) || !/(dark|light)/i.test(a.value)) continue;
36
+ declared = true;
37
+ before.push({ where, attr: a.name, value: a.value });
38
+ el.setAttribute(a.name, a.value.replace(other, want));
39
+ }
40
+ const toks = [...el.classList].filter((c) => MODE.test(c));
41
+ if (toks.length) {
42
+ declared = classed = true;
43
+ before.push({ where, cls: toks });
44
+ for (const c of toks) { el.classList.remove(c); el.classList.add(c.replace(other, want)); }
45
+ }
46
+ }
47
+ if (!declared) {
48
+ const h = document.documentElement;
49
+ for (const name of ['data-theme', 'data-bs-theme']) {
50
+ if (h.hasAttribute(name)) continue; // a theme by name (data-theme="corporate") is left alone
51
+ before.push({ where: 'html', attr: name, value: null });
52
+ h.setAttribute(name, want);
53
+ }
54
+ }
55
+ // a page with no light/dark class gets `dark` for the dark pass even when it declared data-theme:
56
+ // an app whose switch sets both (Lekhak) was otherwise drawn half dark, a state it never shows (Lekhak TF-033)
57
+ const h = document.documentElement;
58
+ if (want === 'dark' && !classed && !h.classList.contains('dark')) { before.push({ where: 'html', added: 'dark' }); h.classList.add('dark'); }
59
+ return before;
60
+ };
61
+
62
+ const RESTORE = (before) => {
63
+ for (const b of [...before].reverse()) {
64
+ const el = b.where === 'body' ? document.body : document.documentElement;
65
+ if (!el) continue;
66
+ if (b.added) el.classList.remove(b.added);
67
+ else if (b.cls) {
68
+ for (const c of [...el.classList]) if (/^(?:(?:theme|mode|scheme|color)[-_])?(?:dark|light)(?:[-_](?:theme|mode|scheme))?$/i.test(c)) el.classList.remove(c);
69
+ for (const c of b.cls) el.classList.add(c);
70
+ } else if (b.value === null) el.removeAttribute(b.attr);
71
+ else el.setAttribute(b.attr, b.value);
72
+ }
73
+ };
74
+
75
+ // Draws the page in `want` and returns the function that puts it back.
76
+ export async function useTheme(page, want, settle = 350) {
77
+ await page.emulateMedia({ colorScheme: want }).catch(() => {});
78
+ const before = await page.evaluate(FORCE, want).catch(() => null);
79
+ if (settle) await page.waitForTimeout(settle);
80
+ return async () => {
81
+ if (before) await page.evaluate(RESTORE, before).catch(() => {});
82
+ await page.emulateMedia({ colorScheme: null }).catch(() => {});
83
+ };
84
+ }
85
+
86
+ // The luminance painted at five points of the window (and behind <body>), 0 = black, 1 = white.
87
+ const SURFACE = () => {
88
+ // the browser converts the colour, never a regex: a page themed in oklch() reads back as "oklch(…)",
89
+ // which an rgb() pattern skipped, so every surface counted as white and no dark theme was seen (Sevak TF-005)
90
+ const cv = document.createElement('canvas'); cv.width = cv.height = 1;
91
+ const cx = cv.getContext('2d', { willReadFrequently: true });
92
+ const parse = (c) => { if (!c || c === 'transparent' || !cx) return null; cx.clearRect(0, 0, 1, 1); cx.fillStyle = 'rgba(0,0,0,0)'; cx.fillStyle = String(c); cx.fillRect(0, 0, 1, 1); const d = cx.getImageData(0, 0, 1, 1).data; return [d[0], d[1], d[2], d[3] / 255]; };
93
+ const lum = (c) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]); };
94
+ const behind = (el) => {
95
+ const layers = [];
96
+ for (let e = el; e; e = e.parentElement) { const c = parse(getComputedStyle(e).backgroundColor); if (c && c[3] > 0) { layers.push(c); if (c[3] >= 0.99) break; } }
97
+ let out = layers.length && layers[layers.length - 1][3] >= 0.99 ? layers.pop() : [255, 255, 255, 1];
98
+ while (layers.length) { const t = layers.pop(), a = t[3]; out = [t[0] * a + out[0] * (1 - a), t[1] * a + out[1] * (1 - a), t[2] * a + out[2] * (1 - a), 1]; }
99
+ return out;
100
+ };
101
+ const w = window.innerWidth, h = window.innerHeight;
102
+ const pts = [[0.5, 0.5], [0.2, 0.3], [0.8, 0.3], [0.2, 0.8], [0.8, 0.8]];
103
+ return [document.body, ...pts.map(([x, y]) => document.elementFromPoint(x * w, y * h))].map((el) => (el ? +lum(behind(el)).toFixed(3) : null));
104
+ };
105
+ export const surface = (page) => page.evaluate(SURFACE).catch(() => null);
106
+ // two surface readings differ when any point's luminance moved by more than 0.15
107
+ export const differs = (a, b) => !!a && !!b && a.some((x, i) => x !== null && b[i] !== null && Math.abs(x - b[i]) > 0.15);
108
+ // a reading that is mostly dark: the theme a mockup that declares none is drawn in
109
+ export const looksDark = (s) => !!s && s.filter((x) => x !== null && x < 0.2).length > s.filter((x) => x !== null).length / 2;
110
+
111
+ // The contrast rule (Lekhak TF-024), measured on what is painted:
112
+ // - every visible text run and form-field value reaches `min` : 1 (3.0, WCAG AA for large text)
113
+ // against the first opaque background behind it; disabled controls are exempt, as WCAG exempts
114
+ // them, and text over an image or a gradient is not measured (the colour behind it is not one);
115
+ // - in dark mode, no box larger than 2% of the window paints a background lighter than 0.8
116
+ // relative luminance (a white card or panel left in a dark shell).
117
+ // Returns { low: [...], light: [...], low_n, light_n }, each list cut to `cap`.
118
+ const AUDIT = ({ dark, min, cap }) => {
119
+ const cv = document.createElement('canvas'); cv.width = cv.height = 1; // oklch() and every other syntax, as SURFACE
120
+ const cx = cv.getContext('2d', { willReadFrequently: true });
121
+ const parse = (c) => { if (!c || c === 'transparent' || !cx) return null; cx.clearRect(0, 0, 1, 1); cx.fillStyle = 'rgba(0,0,0,0)'; cx.fillStyle = String(c); cx.fillRect(0, 0, 1, 1); const d = cx.getImageData(0, 0, 1, 1).data; return [d[0], d[1], d[2], d[3] / 255]; };
122
+ const lum = (c) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]); };
123
+ const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
124
+ const blend = (t, u) => { const a = t[3]; return [t[0] * a + u[0] * (1 - a), t[1] * a + u[1] * (1 - a), t[2] * a + u[2] * (1 - a), 1]; };
125
+ const behind = (el) => {
126
+ const layers = [];
127
+ for (let e = el; e; e = e.parentElement) {
128
+ const cs = getComputedStyle(e);
129
+ if (cs.backgroundImage && cs.backgroundImage !== 'none') return null; // an image or a gradient
130
+ const c = parse(cs.backgroundColor);
131
+ if (c && c[3] > 0) { layers.push(c); if (c[3] >= 0.99) break; }
132
+ }
133
+ let out = layers.length && layers[layers.length - 1][3] >= 0.99 ? layers.pop() : [255, 255, 255, 1];
134
+ while (layers.length) out = blend(layers.pop(), out);
135
+ return out;
136
+ };
137
+ const visible = (el) => {
138
+ const r = el.getBoundingClientRect();
139
+ if (r.width < 2 || r.height < 2) return false;
140
+ for (let e = el; e; e = e.parentElement) {
141
+ const s = getComputedStyle(e);
142
+ if (s.display === 'none' || s.visibility === 'hidden' || Number(s.opacity) < 0.1) return false;
143
+ }
144
+ return r.bottom > 0 && r.right > 0;
145
+ };
146
+ const where = (el) => {
147
+ const id = el.getAttribute('data-testid');
148
+ const cls = String(el.getAttribute('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
149
+ return `${el.tagName.toLowerCase()}${id ? `[data-testid=${id}]` : ''}${cls ? '.' + cls : ''}`;
150
+ };
151
+ const rgb = (c) => `rgb(${c.slice(0, 3).map(Math.round).join(',')})`;
152
+ const EXEMPT = '[disabled],[aria-disabled="true"],svg,script,style,noscript,template';
153
+ const low = [], light = [], seen = new Set();
154
+ const measure = (el, text) => {
155
+ const fg = parse(getComputedStyle(el).color);
156
+ if (!fg || fg[3] < 0.05) return;
157
+ const bg = behind(el);
158
+ if (!bg) return;
159
+ const r = ratio(blend(fg, bg), bg);
160
+ const key = where(el) + '|' + text.slice(0, 30);
161
+ if (r < min && !seen.has(key)) { seen.add(key); low.push({ text: text.slice(0, 40), fg: rgb(fg), bg: rgb(bg), ratio: Math.round(r * 100) / 100, where: where(el) }); }
162
+ };
163
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
164
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
165
+ const text = (n.textContent || '').trim();
166
+ const el = n.parentElement;
167
+ if (!text || !el || !/[\p{L}\p{N}]/u.test(text) || el.closest(EXEMPT) || !visible(el)) continue; // icon glyphs are not text a reader reads
168
+ measure(el, text);
169
+ }
170
+ for (const el of document.querySelectorAll('input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=hidden]):not([type=color]),textarea,select')) {
171
+ if (el.disabled || el.closest(EXEMPT) || !visible(el)) continue;
172
+ const text = String(el.value || '').trim();
173
+ if (text) measure(el, text);
174
+ }
175
+ if (dark) {
176
+ const area = window.innerWidth * window.innerHeight, flagged = [];
177
+ for (const el of document.querySelectorAll('body *')) {
178
+ if (el.closest('img,svg,video,canvas,picture,iframe') || flagged.some((f) => f.contains(el)) || !visible(el)) continue;
179
+ const c = parse(getComputedStyle(el).backgroundColor);
180
+ if (!c || c[3] < 0.5) continue;
181
+ const r = el.getBoundingClientRect();
182
+ const shown = Math.max(0, Math.min(r.right, window.innerWidth) - Math.max(r.left, 0)) * Math.max(0, Math.min(r.bottom, window.innerHeight) - Math.max(r.top, 0));
183
+ if (shown / area > 0.02 && lum(c) > 0.8) { flagged.push(el); light.push({ bg: rgb(c), share: Math.round(shown / area * 100), where: where(el), text: (el.innerText || '').trim().replace(/\s+/g, ' ').slice(0, 40) }); }
184
+ }
185
+ }
186
+ low.sort((a, b) => a.ratio - b.ratio);
187
+ return { low: low.slice(0, cap), light: light.slice(0, cap), low_n: low.length, light_n: light.length };
188
+ };
189
+ export const audit = (page, dark, min = 3.0, cap = 5) => page.evaluate(AUDIT, { dark, min, cap }).catch(() => null);
@@ -62,6 +62,17 @@ def cfg_phase():
62
62
  return 1
63
63
 
64
64
 
65
+ def cfg_project_type():
66
+ """metrics.project_type from core-config.yaml: a library or docs project has no mockups."""
67
+ p = os.path.join(".tfcore", "core-config.yaml")
68
+ if os.path.isfile(p):
69
+ import re
70
+ m = re.search(r"(?m)^\s+project_type:\s*(\S+)", open(p, encoding="utf-8").read())
71
+ if m:
72
+ return m.group(1).strip("'\"").lower()
73
+ return "app"
74
+
75
+
65
76
  def phase_checklist(app, ph):
66
77
  return os.path.join("docs", f"{app}-Checklist.md" if ph <= 1 else f"{app}-P{ph}-Checklist.md")
67
78
 
@@ -167,7 +178,7 @@ def main(argv):
167
178
  cl = checklist(app, argv, ce)
168
179
  prefix = opt(argv, "--prefix", "FN").upper(); section = opt(argv, "--section"); ev = opt(argv, "--evidence")
169
180
  src = opt(argv, "--source", "owner"); mockup = opt(argv, "--mockup")
170
- if prefix == "UI" and not mockup:
181
+ if prefix == "UI" and not mockup and cfg_project_type() not in ("library", "docs"): # TrBlazeUI TF-003
171
182
  print("note: a UI row needs a mockup link; pass --mockup docs/mockups/<screen>.html or the checker will flag it")
172
183
  rid = ce.add_row(cl, prefix, title, acc, section=section, mockup=mockup,
173
184
  remark=f"logged from {'production' if src == 'production' else 'UAT'} {ce.TODAY}" + (f" (evidence: {ev})" if ev else ""))