@techierathore/techieflow 1.0.4 → 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 (148) 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/_owner-language.md +1 -0
  5. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
  6. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
  7. package/.claude/commands/TechieFlow/tasks/amend-docs.md +2 -2
  8. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
  9. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +5 -5
  10. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  11. package/.claude/commands/TechieFlow/tasks/fix-issues.md +3 -2
  12. package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
  14. package/.claude/commands/TechieFlow/tasks/mockups.md +2 -2
  15. package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
  16. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
  17. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  18. package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
  19. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  20. package/.opencode/plugin/techieflow.js +194 -14
  21. package/.tfcore/agents/flow-master.md +2 -0
  22. package/.tfcore/agents/verifier.md +2 -2
  23. package/.tfcore/core-config.yaml +2 -1
  24. package/.tfcore/hooks/block-git.sh +4 -2
  25. package/.tfcore/hooks/guard-build.sh +5 -1
  26. package/.tfcore/hooks/guard-db.sh +112 -13
  27. package/.tfcore/hooks/guard-status-html.sh +86 -2
  28. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  29. package/.tfcore/hooks/guard-verify.sh +8 -3
  30. package/.tfcore/hooks/sweep-artifacts.sh +46 -0
  31. package/.tfcore/hooks/yolo-allow.sh +27 -0
  32. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  33. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  34. package/.tfcore/standards/owner-words.txt +23 -0
  35. package/.tfcore/tasks/_develop-mode.md +34 -0
  36. package/.tfcore/tasks/_owner-language.md +1 -0
  37. package/.tfcore/tasks/_status-update-gate.md +4 -3
  38. package/.tfcore/tasks/_yolo-mode.md +2 -2
  39. package/.tfcore/tasks/amend-docs.md +2 -2
  40. package/.tfcore/tasks/day1-brownfield.md +3 -3
  41. package/.tfcore/tasks/day1-greenfield.md +5 -5
  42. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  43. package/.tfcore/tasks/fix-issues.md +3 -2
  44. package/.tfcore/tasks/generate-html.md +2 -2
  45. package/.tfcore/tasks/handoff-phase.md +2 -2
  46. package/.tfcore/tasks/mockups.md +2 -2
  47. package/.tfcore/tasks/productguide.md +2 -2
  48. package/.tfcore/tasks/render-workflow-docs.md +1 -1
  49. package/.tfcore/tasks/triage-and-fix.md +2 -2
  50. package/.tfcore/tasks/triage-issues.md +2 -2
  51. package/.tfcore/tasks/verify-phase.md +12 -11
  52. package/.tfcore/telemetry/SCHEMA.md +10 -2
  53. package/.tfcore/telemetry/install-metrics.sh +3 -2
  54. package/.tfcore/telemetry/tf-metrics.sh +38 -2
  55. package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
  56. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
  57. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  58. package/.tfcore/templates/v4custom/app-brd-tmpl.md +7 -4
  59. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  60. package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
  61. package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
  62. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  63. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
  64. package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
  65. package/.tfcore/templates/v4custom/html-render-shell.md +6 -2
  66. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  67. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  68. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  69. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  70. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  71. package/.tfcore/utils/tf-appium.mjs +179 -0
  72. package/.tfcore/utils/tf-assets-browser.mjs +96 -0
  73. package/.tfcore/utils/tf-assets.sh +97 -12
  74. package/.tfcore/utils/tf-brd-status.py +14 -5
  75. package/.tfcore/utils/tf-build-list.py +95 -12
  76. package/.tfcore/utils/tf-build-list.sh +2 -1
  77. package/.tfcore/utils/tf-build.sh +163 -15
  78. package/.tfcore/utils/tf-checklist-edit.py +21 -3
  79. package/.tfcore/utils/tf-ci-repro.py +685 -0
  80. package/.tfcore/utils/tf-ci-repro.sh +24 -0
  81. package/.tfcore/utils/tf-day1-files.py +55 -28
  82. package/.tfcore/utils/tf-develop.sh +264 -0
  83. package/.tfcore/utils/tf-devguide-list.py +53 -24
  84. package/.tfcore/utils/tf-doc-check.py +218 -26
  85. package/.tfcore/utils/tf-doc-tests.sh +116 -0
  86. package/.tfcore/utils/tf-emit.sh +132 -45
  87. package/.tfcore/utils/tf-feedback.sh +13 -0
  88. package/.tfcore/utils/tf-fix-close.sh +87 -31
  89. package/.tfcore/utils/tf-goal.sh +12 -12
  90. package/.tfcore/utils/tf-harness.sh +9 -4
  91. package/.tfcore/utils/tf-lock.sh +29 -0
  92. package/.tfcore/utils/tf-log-miss.py +34 -8
  93. package/.tfcore/utils/tf-login.mjs +167 -0
  94. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  95. package/.tfcore/utils/tf-menus.mjs +401 -0
  96. package/.tfcore/utils/tf-misses-md.py +10 -4
  97. package/.tfcore/utils/tf-mockup-parity.mjs +763 -71
  98. package/.tfcore/utils/tf-mockup-parity.sh +25 -2
  99. package/.tfcore/utils/tf-owner-text.py +233 -0
  100. package/.tfcore/utils/tf-owner-text.sh +14 -0
  101. package/.tfcore/utils/tf-phase.sh +38 -4
  102. package/.tfcore/utils/tf-portable.sh +230 -0
  103. package/.tfcore/utils/tf-render-html.py +79 -5
  104. package/.tfcore/utils/tf-req-ids.py +36 -0
  105. package/.tfcore/utils/tf-selfcheck.sh +39 -6
  106. package/.tfcore/utils/tf-split-brd.py +11 -1
  107. package/.tfcore/utils/tf-stack-check.sh +3 -0
  108. package/.tfcore/utils/tf-status-evidence.sh +11 -9
  109. package/.tfcore/utils/tf-status-facts.py +160 -45
  110. package/.tfcore/utils/tf-theme.mjs +189 -0
  111. package/.tfcore/utils/tf-triage.py +74 -17
  112. package/.tfcore/utils/tf-triage.sh +2 -0
  113. package/.tfcore/utils/tf-verify-boot.sh +529 -54
  114. package/.tfcore/utils/tf-verify-emit.sh +57 -17
  115. package/.tfcore/utils/tf-verify-env.sh +33 -4
  116. package/.tfcore/utils/tf-verify-list.py +290 -24
  117. package/.tfcore/utils/tf-verify-list.sh +3 -1
  118. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  119. package/.tfcore/utils/tf-verify-screens.mjs +341 -44
  120. package/.tfcore/utils/tf-verify-screens.sh +23 -5
  121. package/.tfcore/utils/tf-verify-tests.sh +310 -19
  122. package/.tfcore/utils/tf-verify-verdict.py +163 -21
  123. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  124. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  125. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  126. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  127. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  128. package/.tfcore/utils/tf-yolo.sh +31 -3
  129. package/.tfcore/utils/tf_defect.py +27 -0
  130. package/.tfcore/utils/tf_feedback.py +272 -0
  131. package/.tfcore/utils/tf_roadmap.py +35 -0
  132. package/.tfcore/utils/tf_stack_check.py +93 -0
  133. package/README.md +6 -2
  134. package/docs/TechieFlow-Installation.md +5 -3
  135. package/opencode.jsonc +6 -2
  136. package/package.json +1 -1
  137. package/scripts/install.mjs +13 -2
  138. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  139. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  140. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  141. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  142. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  143. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  144. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  145. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  146. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  147. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  148. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -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.
@@ -27,6 +30,7 @@ import re
27
30
  import sys
28
31
 
29
32
  TERMINAL = {"verified", "done (pre-existing)", "n/a"}
33
+ NA = {"n/a"} # terminal, but never counted as verified (TF-018)
30
34
  PASS_THROUGH = {"blocked"} # a library gap; counts as closed for the ladder
31
35
  BUILT = {"implemented", "needs re-verify"}
32
36
  OWNER = {"owner-uat"} # only the owner can close it, from the UsageGuide test plan
@@ -61,6 +65,9 @@ def read(path):
61
65
 
62
66
 
63
67
  def checklist_rows(text):
68
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
69
+ import tf_roadmap
70
+ marked = tf_roadmap.ids(text)
64
71
  rows = []
65
72
  for line in text.splitlines():
66
73
  if not re.match(r"^\s*\|\s*`?REQ-", line):
@@ -69,10 +76,34 @@ def checklist_rows(text):
69
76
  if len(cells) < 3:
70
77
  continue
71
78
  rid = cells[0].strip("`* ")
72
- rows.append({"id": rid, "name": cells[1].strip("`* "), "status": norm_status(cells[2])})
79
+ status = norm_status(cells[2])
80
+ rows.append({"id": rid, "name": cells[1].strip("`* "), "status": status,
81
+ "remarks": cells[4] if len(cells) > 4 else "",
82
+ "roadmap": rid.upper() in marked and status.lower() not in TERMINAL})
73
83
  return rows
74
84
 
75
85
 
86
+ def figures(rows):
87
+ """(verified, counted, not applicable). An N/A row is terminal but was never verified, so it is
88
+ left out of both numbers and named: AppManager read "89 of 89 verified" with REQ-NFR-008 at N/A
89
+ (TF-018). tests/regression/run.sh am_018."""
90
+ na = sum(1 for r in rows if r["status"].lower() in NA)
91
+ return sum(1 for r in rows if r["status"].lower() in TERMINAL - NA), len(rows) - na, na
92
+
93
+
94
+ # The defect marks live in tf_defect.py. A bare ⚠ is not enough: sending "not verifiable here" to a
95
+ # build is the TfLens loop again.
96
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
97
+ from tf_defect import DEFECT # noqa: E402
98
+
99
+
100
+ def has_defect(r):
101
+ """A Needs re-verify row whose Remarks carry a framework defect mark holds a defect no fix has
102
+ cleared, and a verify of the acceptance line cannot see it. So it is fix work, as build-phase step 7
103
+ and tf-build-list say, never a verify (AppManager TF-019). tests/regression/run.sh am_019."""
104
+ return r["status"].lower() == "needs re-verify" and bool(DEFECT.search(r.get("remarks", "")))
105
+
106
+
76
107
  def ladder_rank(status):
77
108
  return LADDER.index(status) if status in LADDER else 0
78
109
 
@@ -142,24 +173,29 @@ def existing_log_rows(status_text):
142
173
  return rows
143
174
 
144
175
 
145
- def handoff_ran(log_rows):
146
- 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
147
189
 
148
190
 
149
191
  def feedback_lines(root, app):
150
- out = []
151
- for path in sorted(glob.glob(os.path.join(root, "docs", f"{app}-*-Feedback.md"))):
152
- lib = re.sub(rf"^{re.escape(app)}-(.+)-Feedback\.md$", r"\1", os.path.basename(path))
153
- text = read(path)
154
- entries = re.split(r"(?m)^###\s+", text)[1:]
155
- open_n = 0
156
- for e in entries:
157
- closed = re.search(r"(?im)^\s*[-*]\s*\*\*(status|resolved|fixed)[^:]*:\*\*\s*(fixed|resolved|closed|done)", e)
158
- if not closed:
159
- open_n += 1
160
- rel = os.path.relpath(path, root).replace(os.sep, "/")
161
- out.append(f"- {lib}: {open_n} open of {len(entries)} — {rel}")
162
- return out or ["- None"]
192
+ # One reader for every script (tf_feedback.py). This function used to count every `###`
193
+ # heading as an entry and accept only a closing line no file used, so TfLens's status read
194
+ # "TechieFlow: 62 open of 62" for 27 entries, 19 of them fixed or closed, and the agent told
195
+ # the owner two fixed problems were open (MISS-TechieFlow-20260911-04).
196
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
197
+ import tf_feedback
198
+ return [tf_feedback.summary_line(p, app) for p in tf_feedback.files(root, app)] or ["- None"]
163
199
 
164
200
 
165
201
  def config_phase(root):
@@ -171,6 +207,13 @@ def config_phase(root):
171
207
  return 1
172
208
 
173
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
+
174
217
  def phase_prefix(app, phase):
175
218
  return f"{app}-" if phase <= 1 else f"{app}-P{phase}-"
176
219
 
@@ -191,9 +234,48 @@ def phase_row(root, app, phase):
191
234
  return name, total
192
235
 
193
236
 
194
- def next_command(app, rows, log_rows, phase=1, verdicts=None):
195
- """Returns (phase, qualifier, cc_line, oc_line, reason)."""
237
+ def usage_guide(app):
238
+ """UsageGuide or Usage-Guide, whichever exists: tf-verify-list.py's reader (AppManager TF-022)."""
239
+ import importlib.util
240
+ spec = importlib.util.spec_from_file_location("tf_verify_list", os.path.join(os.path.dirname(os.path.abspath(__file__)), "tf-verify-list.py"))
241
+ vl = importlib.util.module_from_spec(spec)
242
+ spec.loader.exec_module(vl)
243
+ return vl.usage_guide(app)
244
+
245
+
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."""
196
277
  verdicts = verdicts or {}
278
+ pflag = f" --phase {phase}" if multi else ""
197
279
 
198
280
  def ids(lst):
199
281
  return ", ".join(r["id"] for r in lst[:6]) + (" …" if len(lst) > 6 else "")
@@ -207,17 +289,24 @@ def next_command(app, rows, log_rows, phase=1, verdicts=None):
207
289
  f"{CC}analyst *day1-greenfield {app}", f"{OC}flow-analyst *day1-greenfield {app}",
208
290
  "no BRD and no checklist exist; day-1 stage 1 has not run")
209
291
 
210
- unbuilt = [r for r in rows if r["status"].lower() not in TERMINAL | PASS_THROUGH | BUILT | OWNER]
211
- built = [r for r in rows if r["status"].lower() in BUILT]
212
- owner = [r for r in rows if r["status"].lower() in OWNER]
213
- total = len(rows)
214
- closed = sum(1 for r in rows if r["status"].lower() in TERMINAL)
215
- q = f"{closed} of {total} verified"
216
-
217
- if unbuilt:
218
- return ("Build", f"{len(unbuilt)} not built, {q}",
219
- f"{CC}flow-master *build-phase {app}", f"{OC}flow-master *build-phase {app}",
220
- f"{len(unbuilt)} rows are not built yet: {ids(unbuilt)}")
292
+ # A roadmap row is not this phase's work, so it never decides the next command (TF-017).
293
+ now = [r for r in rows if not r.get("roadmap")]
294
+ unbuilt = [r for r in now if r["status"].lower() not in TERMINAL | PASS_THROUGH | BUILT | OWNER]
295
+ defects = [r for r in now if has_defect(r)]
296
+ waiting, unmeasured = to_verify(now, verdicts)
297
+ built = waiting + unmeasured
298
+ owner = [r for r in now if r["status"].lower() in OWNER]
299
+ verified, counted, na = figures(rows)
300
+ q = f"{verified} of {counted} verified" + (f", {na} not applicable" if na else "")
301
+
302
+ if unbuilt or defects:
303
+ qual = ", ".join(x for x in (f"{len(unbuilt)} not built" if unbuilt else "",
304
+ f"{len(defects)} to fix" if defects else "") if x)
305
+ why = "; ".join(x for x in (f"{len(unbuilt)} rows are not built yet: {ids(unbuilt)}" if unbuilt else "",
306
+ f"{len(defects)} rows carry a defect (⚠ in Remarks) that a fix must clear before "
307
+ f"a verify: {ids(defects)}" if defects else "") if x)
308
+ return ("Build", f"{qual}, {q}",
309
+ f"{CC}flow-master *build-phase {app}", f"{OC}flow-master *build-phase {app}", why)
221
310
  if built:
222
311
  # A row the last verify could not MEASURE is not a row waiting for another verify.
223
312
  # Its tests declined — the state they need does not exist in this environment — so
@@ -225,37 +314,40 @@ def next_command(app, rows, log_rows, phase=1, verdicts=None):
225
314
  # loop that looks like progress. Say what it actually needs: data, a changed
226
315
  # acceptance line, or the row marked N/A. Only the owner can choose. FR-69's sibling
227
316
  # case; the verdicts come from the ledger the verifier already writes.
228
- unmeasured = [r for r in built if verdicts.get(r["id"]) == "NOT-TESTED"]
229
- waiting = [r for r in built if r not in unmeasured]
230
317
  if unmeasured and not waiting:
231
318
  line = (f"(owner) {len(unmeasured)} row(s) cannot be measured here — create the data, "
232
319
  f"change the acceptance line, or mark the row N/A: {ids(unmeasured)}")
233
320
  return ("Verify", f"{len(unmeasured)} not measurable, {q}", line, line,
234
321
  f"every test carrying these ids was skipped, so another verify run changes nothing: {ids(unmeasured)}")
235
- ui = [r for r in waiting if r["id"].startswith("REQ-UI")]
236
- scope = "ui" if len(ui) == len(waiting) else ("functional" if not ui else "all")
322
+ scope = scope_of(waiting)
237
323
  reason = f"{len(waiting)} rows are built and not verified: {ids(waiting)}"
238
324
  if unmeasured:
239
325
  reason += (f"; {len(unmeasured)} more could not be measured (every test skipped) "
240
326
  f"and need the owner: {ids(unmeasured)}")
241
327
  return ("Verify", f"{len(waiting)} to verify, {q}",
242
- 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}",
243
329
  reason)
244
330
  if owner:
245
- if handoff_ran(log_rows):
246
- line = f"(owner-run) docs/{app}-UsageGuide.md — {ids(owner)}"
331
+ if handoff_ran(log_rows, app, phase):
332
+ line = f"(owner-run) {usage_guide(app)} — {ids(owner)}"
247
333
  return ("UAT", f"{len(owner)} owner-run, {q}", line, line,
248
334
  f"{len(owner)} rows are Owner-UAT and handoff has run; the owner closes them")
249
335
  return ("Handoff", f"{len(owner)} owner-run, {q}",
250
336
  f"{CC}flow-master *handoff-phase {app}", f"{OC}flow-master *handoff-phase {app}",
251
337
  f"{len(owner)} rows are Owner-UAT and handoff has not run yet")
252
- 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")
253
345
  line = "(owner) set current_phase to Released after UAT — no agent command"
254
346
  return ("UAT", f"handoff done, {q}", line, line,
255
- "every row is terminal and handoff has run; waiting on the owner")
347
+ "every row in this phase's scope is terminal and handoff has run; waiting on the owner")
256
348
  return ("Handoff", q,
257
349
  f"{CC}flow-master *handoff-phase {app}", f"{OC}flow-master *handoff-phase {app}",
258
- "every row is terminal and handoff has not run yet")
350
+ "every row in this phase's scope is terminal and handoff has not run yet")
259
351
 
260
352
 
261
353
  def main(argv):
@@ -283,15 +375,38 @@ def main(argv):
283
375
  st_text = read(st_path) if os.path.isfile(st_path) else ""
284
376
  log_rows = existing_log_rows(st_text)
285
377
  vdate, vresult = last_verify(root, app)
286
- 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)
287
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}"
400
+ roadmap = [r["id"] for r in rows if r["roadmap"]]
401
+ if roadmap:
402
+ reason += (f"; {len(roadmap)} roadmap row(s) are not in this phase's scope and are left for the owner "
403
+ f"(*amend-docs): {', '.join(roadmap)}")
288
404
  if ptotal or phase_n > 1:
289
405
  tag = f"Phase {phase_n} of {ptotal or '?'}" + (f" ({pname})" if pname else "")
290
406
  phase = f"{tag} · {phase}"
291
407
  reason = f"{reason}; working {cl_rel}"
292
408
 
293
- total = len(rows)
294
- closed = sum(1 for r in rows if r["status"].lower() in TERMINAL)
409
+ verified, counted, na = figures(rows)
295
410
  counts = {}
296
411
  for r in rows:
297
412
  counts[r["status"]] = counts.get(r["status"], 0) + 1
@@ -322,7 +437,7 @@ def main(argv):
322
437
  print()
323
438
  if open_rows:
324
439
  for r in open_rows[:10]:
325
- print(f"- [ ] {r['id']} — {r['name']} ({r['status']})")
440
+ print(f"- [ ] {r['id']} — {r['name']} ({r['status']}{', roadmap — not in this phase' if r['roadmap'] else ''})")
326
441
  if len(open_rows) > 10:
327
442
  print(f"- ({len(open_rows) - 10} more open rows in {cl_rel})")
328
443
  else:
@@ -332,7 +447,7 @@ def main(argv):
332
447
  print("Last five passes; older passes live in `docs/metrics/gates.jsonl`.")
333
448
  print()
334
449
  print("| Date | Phase | Result | Status table |\n|---|---|---|---|")
335
- new_row = f"| {today} | {label} | {closed}/{total} Verified | {cl_rel}#requirements-status |"
450
+ new_row = f"| {today} | {label} | {verified}/{counted} Verified{f', {na} N/A' if na else ''} | {cl_rel}#requirements-status |"
336
451
  for r in (log_rows[-4:] if len(log_rows) > 4 else log_rows):
337
452
  print(r)
338
453
  print(new_row)
@@ -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);