@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
@@ -11,6 +11,8 @@
11
11
  # the row, looked up in runs.jsonl, or left out when there is none
12
12
  # runs.jsonl one `run` record for the verify pass: cmd verify-phase, the rows touched, the build
13
13
  # result, whether YOLO was on
14
+ # Run and gate records carry app_phase, the phase whose checklist was graded (list.json). A second pass
15
+ # of one *verify --phase all starts where the first one's record ended (Sevak TF-005).
14
16
  # Rows the run could not grade (not tested, not observable, not driven) get no record. Telemetry
15
17
  # never blocks: a refused record is printed and the script still exits 0.
16
18
  set -u
@@ -39,32 +41,66 @@ v = json.load(open(os.path.join(d, "verdicts.json"), encoding="utf-8"))
39
41
  run_id = started or v.get("started") or ""
40
42
  now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
41
43
 
42
- def emit(stream, rec):
43
- p = subprocess.run(["bash", EMIT, stream], input=json.dumps(rec), capture_output=True, text=True)
44
- msg = (p.stdout + p.stderr).strip()
45
- if p.returncode != 0 or "refus" in msg.lower() or "error" in msg.lower():
46
- print(f" {stream}: not written — {msg.splitlines()[0][:160] if msg else 'no reason printed'}")
47
- return False
48
- return True
44
+ # A verify chained inside another command is handed that command's start as "the step-0 time", and
45
+ # its record then covered the caller's whole window, so the caller's own record was refused (TF-052).
46
+ # This run's start is its own marker's; with no verify-phase marker (step 0 skipped), when the list
47
+ # was written.
48
+ mk = {}
49
+ try:
50
+ pj = os.path.join(".tfcore", ".session", "phase.json")
51
+ if datetime.datetime.now().timestamp() - os.path.getmtime(pj) < 24 * 3600:
52
+ mk = json.load(open(pj, encoding="utf-8")) or {}
53
+ except Exception:
54
+ pass
55
+ own, caller = "", (mk.get("outer") or {}).get("cmd") or "an earlier command"
56
+ if mk.get("cmd") == "verify-phase":
57
+ own = mk.get("started") or ""
58
+ elif mk.get("started") and mk.get("started") == run_id and os.path.isfile(os.path.join(d, "list.json")):
59
+ own, caller = datetime.datetime.fromtimestamp(os.path.getmtime(os.path.join(d, "list.json")), datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), mk.get("cmd")
60
+ if own and run_id and own > run_id:
61
+ print(f"tf-verify-emit: {run_id} is when {caller} started; this verify started {own} and is recorded from then")
62
+ run_id = own
49
63
 
50
64
  def q(*args):
51
65
  p = subprocess.run(["bash", EMIT, *args], capture_output=True, text=True)
52
66
  return p.stdout.strip()
53
67
 
54
- # origin lookup: the last build-phase or fix-issues run that touched the row
55
68
  runs_path = os.path.join(q("--where") or "docs/metrics", "runs.jsonl")
56
- origin = {}
69
+ runs = []
57
70
  if os.path.isfile(runs_path):
58
71
  for line in open(runs_path, encoding="utf-8", errors="replace"):
59
72
  try:
60
- r = json.loads(line)
73
+ runs.append(json.loads(line))
61
74
  except Exception:
62
- continue
63
- if r.get("kind") != "run" or r.get("backfilled") or r.get("cmd") not in ("build-phase", "fix-issues"):
64
- continue
65
- for rid in r.get("reqs_touched") or []:
66
- if r.get("started", "") >= origin.get(rid, ("", ""))[0]:
67
- origin[rid] = (r.get("started", ""), r.get("cmd"), (r.get("subagents") or ["flow-master"])[0] if r.get("subagents") else "flow-master")
75
+ pass
76
+
77
+ # Sevak TF-005: *verify --phase all is one pass per phase inside one command, all handed the same
78
+ # step-0 time. A pass that finds a run of this app already recorded since that time starts where it
79
+ # ended, so the second pass is not refused as an overlap (SCHEMA §2.7b) and no window is counted twice.
80
+ if run_id:
81
+ after = [r.get("ended", "") for r in runs if r.get("kind") == "run" and not r.get("backfilled")
82
+ and r.get("app") in (None, app) and r.get("started", "") >= run_id and r.get("ended", "") > run_id]
83
+ if after:
84
+ print(f"tf-verify-emit: a run of {app} was recorded since {run_id}; this pass is recorded from its end, {max(after)}")
85
+ run_id = max(after)
86
+ phase = v.get("phase")
87
+
88
+ def emit(stream, rec):
89
+ p = subprocess.run(["bash", EMIT, stream], input=json.dumps(rec), capture_output=True, text=True)
90
+ msg = (p.stdout + p.stderr).strip()
91
+ if p.returncode != 0 or "refus" in msg.lower() or "error" in msg.lower():
92
+ print(f" {stream}: not written — {msg.splitlines()[0][:160] if msg else 'no reason printed'}")
93
+ return False
94
+ return True
95
+
96
+ # origin lookup: the last build-phase or fix-issues run that touched the row
97
+ origin = {}
98
+ for r in runs:
99
+ if r.get("kind") != "run" or r.get("backfilled") or r.get("cmd") not in ("build-phase", "fix-issues"):
100
+ continue
101
+ for rid in r.get("reqs_touched") or []:
102
+ if r.get("started", "") >= origin.get(rid, ("", ""))[0]:
103
+ origin[rid] = (r.get("started", ""), r.get("cmd"), (r.get("subagents") or ["flow-master"])[0] if r.get("subagents") else "flow-master")
68
104
 
69
105
  gates = misses = 0
70
106
  for row in v["rows"]:
@@ -75,6 +111,8 @@ for row in v["rows"]:
75
111
  rec = {"kind": "gate", "app": app, "run_id": run_id, "req_id": rid, "req_class": row["class"],
76
112
  "attempt": int(att) if att.isdigit() else 1, "verdict": row["status"], "gate": row["gate"],
77
113
  "gates_run": row["gates_run"], "failure_class": row["failure_class"], "prior_verdict": row["prior_verdict"]}
114
+ if phase:
115
+ rec["app_phase"] = phase
78
116
  if emit("gates", rec):
79
117
  gates += 1
80
118
  if not row["gate"]:
@@ -104,7 +142,9 @@ rec = {"kind": "run", "app": app, "cmd": "verify-phase", "mode": mode, "yolo": y
104
142
  "build_result": v.get("build_result", "not-run")}
105
143
  if run_id:
106
144
  rec["started"] = run_id
145
+ if phase:
146
+ rec["app_phase"] = phase
107
147
  ok = emit("runs", rec)
108
- print(f"telemetry: {gates} gate record(s), {misses} new miss(es), run record {'written' if ok else 'not written'} (cmd verify-phase, yolo {str(yolo).lower()})")
148
+ print(f"telemetry: {gates} gate record(s), {misses} new miss(es), run record {'written' if ok else 'not written'} (cmd verify-phase, phase {phase or '?'}, yolo {str(yolo).lower()})")
109
149
  PY
110
150
  exit 0
@@ -4,17 +4,30 @@
4
4
  # bash .tfcore/utils/tf-verify-env.sh [--check]
5
5
  #
6
6
  # Ensures, in the current project: package.json; the playwright and @playwright/test packages;
7
- # the Chromium browser; playwright.config.ts with its output pinned under tests/.artifacts/;
7
+ # the Chromium browser; playwright.config.ts with its output pinned under tests/.artifacts/ and its
8
+ # address read from BASE_URL, which tf-verify-tests.sh --base sets (TF-031);
8
9
  # the .gitignore lines for everything this tooling generates. --check reports and changes nothing.
9
10
  # Prints one line per item and READY at the end, or NOT-READY with the one command the owner
10
11
  # must run once (only when a browser install needs sudo and sudo is unavailable).
11
12
  # Exit 0 ready · 1 not ready · 2 node or npm missing.
12
13
  set -u
14
+ source "$(dirname "${BASH_SOURCE[0]}")/tf-portable.sh" # tf_sed_inplace: GNU and BSD sed
13
15
  CHECK=0; [[ "${1:-}" == "--check" ]] && CHECK=1
14
16
  command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 || { echo "NOT-READY node and npm are required"; exit 2; }
15
17
  ok=1
16
18
 
17
19
  say() { echo " $*"; }
20
+ # cfg_sub FILE ALL ERE TEXT: replace the first match of ERE on each line (ALL=1), or only on the
21
+ # first line that matches (ALL=0, what GNU sed's 0,/re/ address did), with TEXT; \n in TEXT is a
22
+ # newline. awk, so it reads the same on GNU and BSD systems.
23
+ cfg_sub() {
24
+ local tmp
25
+ tmp="$(mktemp "$1.XXXXXX")" || return 1
26
+ awk -v all="$2" -v re="$3" -v rep="$4" '
27
+ (all == 1 || !done) && match($0, re) { $0 = substr($0, 1, RSTART - 1) rep substr($0, RSTART + RLENGTH); done = 1 }
28
+ { print }' "$1" > "$tmp" && cat "$tmp" > "$1"
29
+ rm -f "$tmp"
30
+ }
18
31
 
19
32
  # package.json
20
33
  if [[ ! -f package.json ]]; then
@@ -55,7 +68,7 @@ export default defineConfig({
55
68
  testDir: '"'"'./tests/verify'"'"',
56
69
  outputDir: '"'"'./tests/.artifacts/test-results'"'"',
57
70
  reporter: '"'"'line'"'"',
58
- use: { headless: true, screenshot: '"'"'only-on-failure'"'"', trace: '"'"'retain-on-failure'"'"' },
71
+ use: { baseURL: process.env.BASE_URL, headless: true, screenshot: '"'"'only-on-failure'"'"', trace: '"'"'retain-on-failure'"'"' },
59
72
  });'
60
73
  if [[ ! -f playwright.config.ts ]]; then
61
74
  if [[ $CHECK -eq 1 ]]; then say "playwright.config.ts: missing"; ok=0; else printf '%s\n' "$CFG" > playwright.config.ts; say "playwright.config.ts: written"; fi
@@ -63,13 +76,29 @@ elif ! grep -q "tests/.artifacts" playwright.config.ts; then
63
76
  if [[ $CHECK -eq 1 ]]; then say "playwright.config.ts: outputDir not under tests/.artifacts"; ok=0
64
77
  else
65
78
  if grep -q "outputDir" playwright.config.ts; then
66
- sed -i "s#outputDir:[^,]*,#outputDir: './tests/.artifacts/test-results',#" playwright.config.ts
79
+ tf_sed_inplace "s#outputDir:[^,]*,#outputDir: './tests/.artifacts/test-results',#" playwright.config.ts
67
80
  else
68
- sed -i "s#defineConfig({#defineConfig({\n outputDir: './tests/.artifacts/test-results',#" playwright.config.ts
81
+ cfg_sub playwright.config.ts 1 "defineConfig[(][{]" "defineConfig({\n outputDir: './tests/.artifacts/test-results',"
69
82
  fi
70
83
  say "playwright.config.ts: outputDir pinned under tests/.artifacts"
71
84
  fi
72
85
  else say "playwright.config.ts: present, output pinned"; fi
86
+ # the address: tf-verify-tests.sh --base passes it as BASE_URL, which Playwright never reads by
87
+ # itself; a config that ignores it tests whatever is on its default port, an older build included
88
+ # (TfLens TF-031, 2026-09-11). A baseURL of the project's own is kept, as the fallback.
89
+ if [[ -f playwright.config.ts ]] && ! grep -q "BASE_URL" playwright.config.ts; then
90
+ if [[ $CHECK -eq 1 ]]; then say "playwright.config.ts: does not read BASE_URL, so --base is ignored"; ok=0
91
+ elif grep -qE "baseURL[\"']?[[:space:]]*:" playwright.config.ts; then
92
+ tf_sed_inplace -E "s#(baseURL[\"']?[[:space:]]*:[[:space:]]*)#\1process.env.BASE_URL || #" playwright.config.ts
93
+ say "playwright.config.ts: baseURL now reads BASE_URL first, its own address after"
94
+ elif grep -qE "use[[:space:]]*:[[:space:]]*\{" playwright.config.ts; then
95
+ cfg_sub playwright.config.ts 0 "use[[:space:]]*:[[:space:]]*[{]" "use: { baseURL: process.env.BASE_URL,"
96
+ say "playwright.config.ts: baseURL added, read from BASE_URL"
97
+ else
98
+ cfg_sub playwright.config.ts 0 "defineConfig[(][{]" "defineConfig({\n use: { baseURL: process.env.BASE_URL },"
99
+ say "playwright.config.ts: baseURL added, read from BASE_URL"
100
+ fi
101
+ fi
73
102
 
74
103
  # .gitignore
75
104
  missing=()
@@ -1,19 +1,24 @@
1
1
  #!/usr/bin/env python3
2
2
  """tf-verify-list.py — the working list of a verify run (see tf-verify-list.sh).
3
3
 
4
- bash .tfcore/utils/tf-verify-list.sh <App> <scope> [--phase N] [--json-out <file>]
4
+ bash .tfcore/utils/tf-verify-list.sh <App> <scope> [--phase N|all] [--json-out <file>]
5
5
 
6
6
  scope: ui (REQ-UI rows) · functional (REQ-FN, REQ-NFR, REQ-RAG) · all · a comma list of ids.
7
+ --phase N grades phase N's checklist (default appPhase); --phase all only prints the phases that have
8
+ a checklist, phase 1 first, as "PHASES 1 2 3", and writes nothing: the task runs one pass per phase.
7
9
  Reads the phase's checklist, the UIDesign, the BRD screens table and the UsageGuide, and prints:
8
10
  - every row in scope with its status, acceptance line, screen, route, mockup and perf budget;
9
11
  N/A rows are skipped, every other status is graded (a Verified row is re-confirmed)
10
12
  - the screens to drive, each with its route, its mockup and the rows it owns; a dialog row
11
13
  is listed under its parent screen (Schemas §2: a dialog is verified on its page)
12
14
  - rows whose screen could not be resolved (graded by their test only)
13
- - the test users from the UsageGuide
15
+ - the test users from the UsageGuide, as plain text (no Markdown)
16
+ - for each screen, from the code: the page file that serves its route, that file's project, and
17
+ the labels of the links or buttons to click from the app's menu to reach it (Lekhak TF-027)
14
18
  Writes the same as JSON (default tests/.artifacts/verify/list.json) for the other verify scripts.
15
19
  Python 3 standard library only. Exit 0 printed, 2 could not run.
16
20
  """
21
+ import html
17
22
  import json
18
23
  import os
19
24
  import re
@@ -71,14 +76,22 @@ def entries_of(text):
71
76
  out, current = {}, ""
72
77
  lines = text.splitlines()
73
78
  for i, line in enumerate(lines):
74
- m = re.match(r"^##\s+(.+)$", line)
79
+ # a heading one level down (`### Page: …` under `## UI / Pages`) names the section too, and any
80
+ # other one (`### Cross-page: …`) ends the page above it
81
+ m = re.match(r"^#{2,3}\s+(.+)$", line)
75
82
  if m:
76
83
  current = re.sub(r"^(?:page|screen)\s*:\s*", "", m.group(1).strip(), flags=re.I)
77
84
  continue
78
85
  a = re.search(r"<a id=['\"]d-(req-[a-z]+-\d+)['\"]", line, re.I)
79
86
  if a:
80
87
  rid = a.group(1).upper()
81
- e = {"section": current, "acceptance": "", "mockup": "", "brd": "", "perf_budget": ""}
88
+ # an anchor written above its own page heading belongs to that page, not the one before
89
+ # (AppManager's older entries: `<a id>` then `### Page:` then the row)
90
+ nxt = next((l for l in lines[i + 1:i + 3] if l.strip()), "")
91
+ h = re.match(r"^#{2,3}\s+(?:page|screen)\s*:\s*(.+)$", nxt, re.I)
92
+ if h:
93
+ current = h.group(1).strip()
94
+ e = {"section": current, "acceptance": "", "mockup": "", "brd": "", "perf_budget": "", "zoom_check": ""}
82
95
  for j in range(i, min(i + 14, len(lines))):
83
96
  l = lines[j]
84
97
  if re.search(r"\*Acceptance:?\*|^\s*[-*]\s*Acceptance:", l, re.I) and not e["acceptance"]:
@@ -92,6 +105,9 @@ def entries_of(text):
92
105
  pb = re.search(r"perf-budget:\s*([^|`]+)", l)
93
106
  if pb and not e["perf_budget"]:
94
107
  e["perf_budget"] = pb.group(1).strip()
108
+ zc = re.search(r"zoom-check:\s*(keys|kept)\b", l) # coding-standards-dotnet.md §9b
109
+ if zc and not e["zoom_check"]:
110
+ e["zoom_check"] = zc.group(1)
95
111
  if j > i and re.match(r"^\s*(?:[-*]\s*)?<a id=|^## ", l):
96
112
  break # the next entry starts: nothing below belongs to this row
97
113
  out[rid] = e
@@ -109,11 +125,62 @@ def screens_of_uidesign(path):
109
125
  m = re.match(r"###\s+Screen:\s*(.+?)\s*\(`?([^)`]+)`?\)\s*$", p.splitlines()[0] if p.strip() else "")
110
126
  if not m:
111
127
  continue
112
- mk = re.search(r"((?:docs/)?mockups/[\w./-]+\.html)", p)
113
- mock = mk.group(1) if mk else ""
114
- if mock and not mock.startswith("docs/"):
115
- mock = "docs/" + mock
116
- out.append({"name": m.group(1).strip(), "route": m.group(2).strip(), "mockup": mock})
128
+ mocks = []
129
+ for mk in re.findall(r"((?:docs/)?mockups/[\w./-]+\.html)", p):
130
+ mk = mk if mk.startswith("docs/") else "docs/" + mk
131
+ if mk not in mocks:
132
+ mocks.append(mk)
133
+ name, route = m.group(1).strip(), m.group(2).strip()
134
+ tabs = split_by_mockup(name, route, mocks)
135
+ out.extend(tabs or [{"name": name, "route": route, "mockup": mocks[0] if mocks else ""}])
136
+ return out
137
+
138
+
139
+ def split_by_mockup(name, route, mocks):
140
+ """One screen per mockup when the route takes one value and the entry links a mockup per value:
141
+ `/settings/{tab}` with settings-providers.html, settings-agents.html … is seven screens,
142
+ `/settings/providers` and so on. Kept as one screen it was never driven, and its rows were
143
+ Verified on their tests alone (Chatur TF-003). [] when the route or the mockups do not fit."""
144
+ params = re.findall(r"\{[^}]+\}", route)
145
+ if len(params) != 1 or len(mocks) < 2:
146
+ return []
147
+ stems = [os.path.basename(m)[:-5] for m in mocks]
148
+ prefix = os.path.commonprefix(stems)
149
+ prefix = prefix[:prefix.rfind("-") + 1] if "-" in prefix else ""
150
+ values = [s[len(prefix):] for s in stems]
151
+ if not prefix or not all(re.fullmatch(r"[a-z0-9][a-z0-9-]*", v) for v in values):
152
+ return []
153
+ return [{"name": f"{name} / {v}", "route": route.replace(params[0], v), "mockup": mk,
154
+ "route_pattern": route, "parent": name} for v, mk in zip(values, mocks)]
155
+
156
+
157
+ def seed_of(screen):
158
+ """tests/verify/seed/<mockup name>.sh, when the project has one: it puts the running app in the
159
+ state the mockup draws before the screen is driven (Chatur TF-002). tf-verify-screens runs it."""
160
+ stem = os.path.basename(screen.get("mockup") or "")[:-5] or re.sub(r"[^a-z0-9]+", "-", screen["name"].lower()).strip("-")
161
+ p = os.path.join("tests", "verify", "seed", f"{stem}.sh")
162
+ return p if os.path.isfile(p) else ""
163
+
164
+
165
+ def screens_of_checklist(text, ent):
166
+ """[{name, route, mockup}] from the checklist's own '## Page: Name (`/route`, …)' headings, at ## or
167
+ ###, for a project with no UIDesign: the first route that can be opened as typed (no `{id}`),
168
+ the mockup from the first entry under the heading. A heading with no such route names no screen.
169
+ Without this every UI row of such a project was graded by its test only (AppManager TF-016)."""
170
+ out, seen = [], set()
171
+ for line in text.splitlines():
172
+ m = re.match(r"^#{2,3}\s+(?:page|screen)\s*:\s*(.+?)\s*\((.*)\)\s*$", line, re.I)
173
+ if not m or norm_name(m.group(1)) in seen:
174
+ continue
175
+ inner = m.group(2)
176
+ cands = re.findall(r"`([^`]+)`", inner) or re.split(r"\s*,\s*", inner)
177
+ route = next((c.strip() for c in cands if c.strip().startswith("/") and "{" not in c and " " not in c.strip()), "")
178
+ if not route:
179
+ continue
180
+ section = re.sub(r"^#+\s+(?:page|screen)\s*:\s*", "", line.strip(), flags=re.I)
181
+ mock = next((e["mockup"] for e in ent.values() if e["section"] == section and e["mockup"]), "")
182
+ seen.add(norm_name(m.group(1)))
183
+ out.append({"name": m.group(1).strip(), "route": route, "mockup": mock, "section": section})
117
184
  return out
118
185
 
119
186
 
@@ -131,17 +198,158 @@ def dialogs_of_brd(path):
131
198
  return out
132
199
 
133
200
 
201
+ def usage_guide(app):
202
+ """docs/{App}-UsageGuide.md, or docs/{App}-Usage-Guide.md: the two spellings tf-doc-check.py
203
+ accepts. Only the first was looked for, so AppManager's guide read as having no test users
204
+ (AppManager TF-009)."""
205
+ for name in (f"{app}-UsageGuide.md", f"{app}-Usage-Guide.md"):
206
+ p = os.path.join("docs", name)
207
+ if os.path.isfile(p):
208
+ return p
209
+ return os.path.join("docs", f"{app}-UsageGuide.md")
210
+
211
+
212
+ def plain(cell):
213
+ """A Markdown table cell as plain text: code spans, emphasis and links unwrapped."""
214
+ t = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", cell)
215
+ t = re.sub(r"`([^`]*)`", r"\1", t).replace("`", "")
216
+ return re.sub(r"(\*\*|__|\*)", "", t).strip()
217
+
218
+
219
+ # --- how each screen is reached, read from the code (Lekhak TF-027) ---------------------------------
220
+ # A Blazor Hybrid head on a Mac shows mac2 no data-testid and no URL, so its screens are reached by
221
+ # clicking what a person clicks. The code says what that is: a page declares its route with @page, and
222
+ # a link or button anywhere names a route in href/Href with its visible text. A link in a file that is
223
+ # not a page (a layout, the menu) is on every screen; a link on a page is on that page only. Only the
224
+ # code of the screen's own project and the projects it references counts, so a website page is never
225
+ # looked for in the desktop app's menu.
226
+ CODE_DIRS = ("src", "source")
227
+ SKIP_DIRS = {"bin", "obj", "node_modules", ".git", ".tfbuild", "tests", "wwwroot"}
228
+ LINK = re.compile(r"<([A-Za-z][\w.]*)\b([^>]*?)\b[Hh]ref\s*=\s*\"([^\"]+)\"([^>]*)>(.*?)</\1\s*>", re.S)
229
+
230
+
231
+ def route_key(r):
232
+ """'/admin/llm-signin/{ProviderId:long}' and 'admin/llm-signin/@p.Id' -> ('admin', 'llm-signin', '*')"""
233
+ r = re.split(r"[?#]", r.strip())[0].strip("/").lower()
234
+ return tuple("*" if ("{" in seg or "@" in seg) else seg for seg in r.split("/")) if r else ()
235
+
236
+
237
+ def label_of(inner):
238
+ t = re.sub(r"<[^>]+>", " ", inner)
239
+ if "@" in t:
240
+ return "" # a label the code computes is not known here
241
+ t = html.unescape(t)
242
+ t = "".join(ch for ch in t if ch.isalnum() or ch in " +-'&/.,()") # icon glyphs out
243
+ return re.sub(r"\s+", " ", t).strip()
244
+
245
+
246
+ def code_map():
247
+ """-> projects {dir: [referenced dirs]}, pages {route_key: (file, project dir)}, links [(file, own keys, project, key, label)]"""
248
+ projects, pages, links, files = {}, {}, [], []
249
+ for top in CODE_DIRS:
250
+ for d, subs, names in os.walk(top):
251
+ subs[:] = [x for x in subs if x not in SKIP_DIRS and not x.startswith(".")]
252
+ for n in names:
253
+ p = os.path.join(d, n)
254
+ if n.endswith(".csproj"):
255
+ refs = re.findall(r'<ProjectReference\s+Include="([^"]+)"', read(p))
256
+ projects[d] = [os.path.normpath(os.path.join(d, os.path.dirname(x.replace("\\", "/")))) for x in refs]
257
+ elif n.endswith(".razor") or n.endswith(".cshtml"):
258
+ files.append(p)
259
+ def project_of(f):
260
+ d = os.path.dirname(f)
261
+ while d and d not in projects:
262
+ nd = os.path.dirname(d)
263
+ if nd == d:
264
+ return ""
265
+ d = nd
266
+ return d
267
+ for f in files:
268
+ t = read(f)
269
+ own = [route_key(r) for r in re.findall(r'(?m)^\s*@page\s+"([^"]+)"', t)]
270
+ for k in own:
271
+ pages.setdefault(k, (f, project_of(f)))
272
+ for m in LINK.finditer(t):
273
+ lab = label_of(m.group(5))
274
+ if lab and not m.group(3).startswith(("http:", "https:", "mailto:", "#")):
275
+ links.append((f, own, project_of(f), route_key(m.group(3)), lab))
276
+ return projects, pages, links
277
+
278
+
279
+ def closure(projects, d):
280
+ seen, todo = [], [d]
281
+ while todo:
282
+ x = todo.pop()
283
+ if x in seen:
284
+ continue
285
+ seen.append(x)
286
+ todo.extend(projects.get(x, []))
287
+ return seen
288
+
289
+
290
+ def matches(a, b):
291
+ return len(a) == len(b) and all(x == y or "*" in (x, y) for x, y in zip(a, b))
292
+
293
+
294
+ def navigation(screens):
295
+ """Adds page, project and nav (labels to click, in order) to each screen the code serves."""
296
+ projects, pages, links = code_map()
297
+ for s in screens:
298
+ k = route_key(s["route"])
299
+ hit = next(((f, pr) for pk, (f, pr) in pages.items() if matches(pk, k)), None)
300
+ if not hit:
301
+ continue
302
+ s["page"], s["project"] = hit[0], hit[1]
303
+ scope = set(closure(projects, hit[1]))
304
+ mine = [l for l in links if l[2] in scope]
305
+ # breadth first, from the links on every screen (a layout, the menu: files that are not pages)
306
+ paths, todo = {}, []
307
+ for f, own, _, to, lab in mine:
308
+ if not own and to not in paths:
309
+ paths[to] = [lab]; todo.append(to)
310
+ while todo:
311
+ cur = todo.pop(0)
312
+ for f, own, _, to, lab in mine:
313
+ if any(matches(o, cur) for o in own) and to not in paths:
314
+ paths[to] = paths[cur] + [lab]; todo.append(to)
315
+ nav = next((p for to, p in paths.items() if matches(to, k)), None)
316
+ if nav is not None:
317
+ s["nav"] = nav
318
+
319
+
134
320
  def test_users(path):
321
+ """The rows of the guide's Test users table. The heading may sit at ## or ### (a guide that
322
+ numbers its chapters nests it one level down), and the table may be the template's
323
+ `| # | User | Password source | Role |` or any table whose header names a user or email column
324
+ and a role column, read by those names (AppManager TF-009)."""
135
325
  users = []
136
326
  if not os.path.isfile(path):
137
327
  return users
138
- m = re.search(r"(?ms)^##\s+(?:\d+[.)]\s*)?Test users[^\n]*\n(.*?)(?=^## |\Z)", read(path))
328
+ m = re.search(r"(?ms)^#{2,3}\s+(?:\d+[.)]\s*)?Test users[^\n]*\n(.*?)(?=^#{1,3} |\Z)", read(path))
139
329
  if not m:
140
330
  return users
331
+ cols = None
141
332
  for line in m.group(1).splitlines():
142
- c = [x.strip().strip("`* ") for x in line.strip().strip("|").split("|")]
143
- if len(c) >= 4 and c[0].isdigit():
144
- users.append({"user": c[1], "password_source": c[2], "role": c[3]})
333
+ if not line.strip().startswith("|"):
334
+ continue
335
+ c = [x.strip() for x in line.strip().strip("|").split("|")]
336
+ if cols is None:
337
+ low = [x.lower() for x in c]
338
+ def col(*names):
339
+ return next((i for i, h in enumerate(low) if any(n in h for n in names)), None)
340
+ cols = {"user": col("user", "email", "login", "account"), "pw": col("password", "secret"), "role": col("role")}
341
+ if cols["user"] is None or cols["role"] is None:
342
+ cols = {"user": 1, "pw": 2, "role": 3} # the template's numbered shape
343
+ continue
344
+ if set("".join(c)) <= set("-: "):
345
+ continue # the header rule
346
+ if len(c) > max(cols["user"], cols["role"]) and plain(c[cols["user"]]) and not plain(c[cols["user"]]).isdigit():
347
+ # The cell is Markdown. The user is its first code span when it has one ("`a@b.c` (username
348
+ # `a`)" is a@b.c); every field is plain text, never with a stray backtick (Lekhak TF-027).
349
+ u = re.match(r"\s*`([^`]+)`", c[cols["user"]])
350
+ users.append({"user": u.group(1).strip() if u else plain(c[cols["user"]]),
351
+ "password_source": plain(c[cols["pw"]]) if cols["pw"] is not None and len(c) > cols["pw"] else "",
352
+ "role": plain(c[cols["role"]])})
145
353
  return users
146
354
 
147
355
 
@@ -166,32 +374,67 @@ def screen_from_acceptance(acc):
166
374
  return hits[-1].strip() if hits else ""
167
375
 
168
376
 
377
+ def checklist_of(app, phase):
378
+ return os.path.join("docs", f"{app}-Checklist.md" if phase <= 1 else f"{app}-P{phase}-Checklist.md")
379
+
380
+
381
+ def phases_of(app):
382
+ """The phase numbers that have a checklist, phase 1 first."""
383
+ found = [1] if os.path.isfile(checklist_of(app, 1)) else []
384
+ for f in os.listdir("docs") if os.path.isdir("docs") else []:
385
+ m = re.fullmatch(re.escape(app) + r"-P(\d+)-Checklist\.md", f)
386
+ if m and int(m.group(1)) > 1:
387
+ found.append(int(m.group(1)))
388
+ return sorted(set(found))
389
+
390
+
169
391
  def main(argv):
170
392
  if len(argv) < 3 or argv[1] in ("-h", "--help"):
171
393
  print(__doc__)
172
394
  return 0
173
395
  app, scope = argv[1], argv[2]
396
+ # Sevak TF-005: *verify graded only appPhase's checklist, so phases 1 and 2 (86 rows waiting) were
397
+ # never graded. --phase N grades that phase's checklist; --phase all prints the phases to grade, in
398
+ # order, and the task runs one pass per phase. Every later script reads the checklist from list.json.
399
+ have = phases_of(app)
174
400
  phase = int(cfg("appPhase", "1"))
175
401
  if "--phase" in argv:
176
- phase = int(argv[argv.index("--phase") + 1])
402
+ val = argv[argv.index("--phase") + 1] if argv.index("--phase") + 1 < len(argv) else ""
403
+ if val == "all":
404
+ if not have:
405
+ die(f"no checklist for {app} under docs/")
406
+ print(f"# tf-verify-list — {app} — phase all — scope {scope}")
407
+ print("PHASES " + " ".join(str(p) for p in have))
408
+ print("Grade them one at a time, in this order: steps 1 and 4 to 6 of verify-phase.md with "
409
+ + ", then ".join(f"--phase {p} ({checklist_of(app, p)})" for p in have) + ". Nothing was written.")
410
+ return 0
411
+ if not val.isdigit() or int(val) < 1:
412
+ die(f"--phase takes a phase number or all, not {val or 'nothing'}")
413
+ phase = int(val)
177
414
  out_path = os.path.join("tests", ".artifacts", "verify", "list.json")
178
415
  if "--json-out" in argv:
179
416
  out_path = argv[argv.index("--json-out") + 1]
180
417
  pfx = f"{app}-" if phase <= 1 else f"{app}-P{phase}-"
181
- cl = os.path.join("docs", f"{pfx}Checklist.md")
418
+ cl = checklist_of(app, phase)
182
419
  if not os.path.isfile(cl):
183
- die(f"{cl} does not exist")
420
+ die(f"{cl} does not exist" + (f"; the phases with a checklist are {', '.join(map(str, have))}" if have else ""))
184
421
  text = read(cl)
185
422
  rows = rows_of(text)
186
423
  if not rows:
187
424
  die(f"{cl} has no REQ rows")
188
425
  ent = entries_of(text)
189
- screens = screens_of_uidesign(os.path.join("docs", f"{pfx}UIDesign.md"))
426
+ screens, source = screens_of_uidesign(os.path.join("docs", f"{pfx}UIDesign.md")), "UIDesign"
427
+ if not screens:
428
+ screens, source = screens_of_checklist(text, ent), "checklist's Page headings"
190
429
  dialogs = dialogs_of_brd(os.path.join("docs", f"{pfx}BRD.md"))
191
- users = test_users(os.path.join("docs", f"{app}-UsageGuide.md"))
430
+ users = test_users(usage_guide(app))
192
431
  by_name = {norm_name(s["name"]): s for s in screens}
432
+ for s in screens: # a split screen's own name reaches its first tab (Chatur TF-003)
433
+ if s.get("parent"):
434
+ by_name.setdefault(norm_name(s["parent"]), s)
193
435
  by_mock = {s["mockup"]: s for s in screens if s["mockup"]}
194
436
  by_route = {s["route"].lower(): s for s in screens}
437
+ by_section = {s["section"]: s for s in screens if s.get("section")}
195
438
 
196
439
  work, skipped, unresolved = [], 0, []
197
440
  for r in rows:
@@ -200,10 +443,15 @@ def main(argv):
200
443
  if r["status"] in NA:
201
444
  skipped += 1
202
445
  continue
203
- e = ent.get(r["id"], {"section": "", "acceptance": "", "mockup": "", "brd": "", "perf_budget": ""})
446
+ e = ent.get(r["id"], {"section": "", "acceptance": "", "mockup": "", "brd": "", "perf_budget": "", "zoom_check": ""})
204
447
  row = dict(r, acceptance=e["acceptance"], mockup=e["mockup"], brd=e["brd"], perf_budget=e["perf_budget"],
448
+ zoom_check=e.get("zoom_check", ""),
205
449
  section=e["section"], screen="", route="", dialog="")
206
450
  cand = [screen_from_acceptance(e["acceptance"]), e["section"], r["title"]]
451
+ if e["section"] in by_section: # a screen read from the checklist: the heading the row sits under
452
+ s = by_section[e["section"]]
453
+ row["screen"], row["route"] = s["name"], s["route"]
454
+ cand = []
207
455
  for c in cand:
208
456
  n = norm_name(c)
209
457
  if not n:
@@ -218,6 +466,10 @@ def main(argv):
218
466
  if not row["screen"] and e["mockup"] in by_mock:
219
467
  s = by_mock[e["mockup"]]
220
468
  row["screen"], row["route"] = s["name"], s["route"]
469
+ # a row on a split screen belongs to the tab whose mockup it names
470
+ tab = by_mock.get(e["mockup"])
471
+ if row["screen"] and tab and tab.get("parent") and by_name.get(norm_name(row["screen"]), {}).get("parent") == tab["parent"]:
472
+ row["screen"], row["route"] = tab["name"], tab["route"]
221
473
  if not row["screen"] and row["class"] != "NFR":
222
474
  unresolved.append(row["id"])
223
475
  work.append(row)
@@ -226,7 +478,8 @@ def main(argv):
226
478
  for s in screens:
227
479
  owned = [r["id"] for r in work if r["screen"] == s["name"]]
228
480
  if owned:
229
- screen_list.append(dict(s, rows=owned))
481
+ screen_list.append(dict(s, rows=owned, seed=seed_of(s)))
482
+ navigation(screen_list)
230
483
 
231
484
  print(f"# tf-verify-list — {app} — {cl} — phase {phase} — scope {scope}")
232
485
  counts = {}
@@ -236,14 +489,27 @@ def main(argv):
236
489
  if not work:
237
490
  print("NOTHING: no row in scope. Run the status gate and stop.")
238
491
  print()
239
- print(f"## Screens to drive ({len(screen_list)} of {len(screens)} in the UIDesign)")
492
+ print(f"## Screens to drive ({len(screen_list)} of {len(screens)} in the {source})")
240
493
  for s in screen_list:
241
- print(f"- {s['name']} ({s['route']}) — mockup {s['mockup'] or 'none'} — rows {', '.join(s['rows'])}")
494
+ print(f"- {s['name']} ({s['route']}) — mockup {s['mockup'] or 'none'}"
495
+ + (f" — seed {s['seed']}" if s["seed"] else "")
496
+ + (f" — served by {s['project']}" if s.get("project") else "")
497
+ + (f" — reached by clicking {' > '.join(repr(x) for x in s['nav'])}" if s.get("nav") else "")
498
+ + f" — rows {', '.join(s['rows'])}")
499
+ par = [f"--screen {os.path.basename(s['mockup'])[:-5]}={s['route']}" for s in screen_list
500
+ if s["mockup"] and "{" not in s["route"]]
501
+ if par:
502
+ # --list carries each screen's mockup path: the --screen name is the file's stem only, so
503
+ # docs/mockups/admin/prompt-manager.html read as NO-MOCKUP (Lekhak TF-022)
504
+ print(f"Mockup parity: {' '.join(par)} --list {out_path}")
242
505
  print()
243
506
  print("## Rows")
244
507
  for r in work:
245
508
  where = f"{r['screen']} {r['route']}" + (f" — dialog {r['dialog']}" if r["dialog"] else "") if r["screen"] else "no screen resolved"
246
- extra = f" — perf-budget: {r['perf_budget']}" if r["perf_budget"] else ""
509
+ extra = (f" — perf-budget: {r['perf_budget']}" if r["perf_budget"] else "") \
510
+ + (f" — zoom-check: {r['zoom_check']} (bash .tfcore/utils/tf-verify-zoom.sh --appium|--cdp <url>"
511
+ + (" --expect-restored 1.3, after a run with --leave 3 and a restart" if r.get("zoom_check") == "kept" else "")
512
+ + f" --json-out tests/.artifacts/verify/zoom/{r['id']}.json)" if r.get("zoom_check") else "")
247
513
  print(f"- {r['id']} [{r['status_raw']}] {r['title']} — {where}{extra}")
248
514
  print(f" acceptance: {r['acceptance'] or 'MISSING — the row cannot be graded; fix the checklist'}")
249
515
  if unresolved:
@@ -259,7 +525,7 @@ def main(argv):
259
525
  os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
260
526
  with open(out_path, "w", encoding="utf-8") as f:
261
527
  json.dump({"app": app, "scope": scope, "phase": phase, "kind": cfg("appKind", "app"), "checklist": cl,
262
- "rows": work, "screens": screen_list, "unresolved": unresolved, "test_users": users,
528
+ "rows": work, "screens": screen_list, "screens_source": source, "unresolved": unresolved, "test_users": users,
263
529
  "skipped_na": skipped, "total": len(rows)}, f, indent=1)
264
530
  print()
265
531
  print(f"JSON: {out_path}")
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env bash
2
2
  # tf-verify-list.sh — the working list of a verify run (Sitting 4c, 2026-09-06).
3
3
  #
4
- # bash .tfcore/utils/tf-verify-list.sh <App> <scope> [--phase N] [--json-out <file>]
4
+ # bash .tfcore/utils/tf-verify-list.sh <App> <scope> [--phase N|all] [--json-out <file>]
5
5
  #
6
+ # --phase N grades phase N's checklist (default appPhase); --phase all prints the phases to grade, in
7
+ # order, and writes nothing (Sevak TF-005).
6
8
  # scope: ui · functional · all · a comma list of REQ ids. Prints every row to grade with its
7
9
  # acceptance line, screen, route, mockup and perf budget, the screens to drive, and the test
8
10
  # users; writes tests/.artifacts/verify/list.json for the other verify scripts. Skips N/A rows