@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
@@ -18,10 +18,24 @@
18
18
  # relayed to every interface by tf-cdp-relay.ps1, and reached from WSL over CDP. Prints
19
19
  # BOOTED mode=cdp url=http://<host>:9223. Proven on MyDiary, 2026-09-06.
20
20
  # static a folder of HTML served by python3 (the framework self-test, a mockup set).
21
- # android, ios, maccatalyst: no driver ships in this framework version. NONE with that reason;
22
- # their rows are recorded as not verified, never as static-only passes.
23
- # Without --head the script picks web when a web project exists, else windows when a MAUI project
24
- # with a windows target exists, else NONE. --project names the project when there are several.
21
+ # maccatalyst a MAUI project with a net*-maccatalyst target, on a Mac only: built by tf-build.sh,
22
+ # its .app started here, and reached over Appium's mac2 driver at the address in
23
+ # core-config.yaml runtimeVerification.appium.maccatalyst.url (default
24
+ # http://localhost:4723; a local Appium that is down is started and later stopped).
25
+ # Prints BOOTED mode=appium url=<appium> bundle=<id>. An SDK that names an older Xcode
26
+ # is built again with -p:ValidateXcodeVersion=false, and the state says so. A Blazor Hybrid
27
+ # head is driven without control names (its data-testid is not readable on a Mac): the
28
+ # state says webview, and those rows say the name check was not measured.
29
+ # BOOTED only once a mac2 session has opened and the app shows a window of its own, not a
30
+ # system dialog; that window is saved as boot-<port>-window.png (Lekhak TF-026). Before the
31
+ # build it checks the two things a fresh macOS 27 / Xcode 27 machine lacks, and names the fix:
32
+ # Automation Mode allowed without a password, and a mac2 driver new enough for this Xcode.
33
+ # `stop` quits the app as Cmd-Q does, and kills it only when it has not quit in 15 s.
34
+ # android, ios: no driver ships in this framework version. NONE with that reason; their rows
35
+ # are recorded as not verified, never as static-only passes.
36
+ # Without --head the script picks web when a web project exists, else (on a Mac) maccatalyst when a
37
+ # MAUI project with that target exists, else windows when a MAUI project with a windows target
38
+ # exists, else NONE. --project names the project when there are several.
25
39
  # The state goes to tests/.artifacts/verify/boot-<port>.json and the app log to app-<port>.log, one pair
26
40
  # per app, so builders booting side by side never empty or stop each other's (TF-034); boot.json is a
27
41
  # copy of the latest start's, which the verdict reads. `stop --port N` stops that app only; a bare
@@ -71,6 +85,7 @@ find_projects() { # prints csproj paths under src/, source/, the root and one le
71
85
  }
72
86
  is_web() { grep -qE 'Microsoft\.NET\.Sdk\.Web|Microsoft\.NET\.Sdk\.BlazorWebAssembly' "$1"; }
73
87
  is_maui_win() { grep -qiE '<UseMaui>[[:space:]]*true|Microsoft\.NET\.Sdk\.Maui' "$1" && grep -qE 'net[0-9.]+-windows' "$1"; }
88
+ is_maui_mac() { grep -qiE '<UseMaui>[[:space:]]*true|Microsoft\.NET\.Sdk\.Maui' "$1" && grep -qE 'net[0-9.]+-maccatalyst' "$1"; }
74
89
 
75
90
  poll_http() { # url seconds pid-to-watch(optional) -> 0 when it answers
76
91
  local url="$1" secs="$2" pid="${3:-}" i=0 code
@@ -129,6 +144,24 @@ d, port = sys.argv[1], sys.argv[2]
129
144
  main = os.path.join(d, "boot.json")
130
145
  path = os.path.join(d, f"boot-{port}.json") if port else main
131
146
  s = json.load(open(path))
147
+ # A Mac Catalyst head is quit the way Cmd-Q quits it. Killed, macOS offered at the next start to reopen
148
+ # the windows of a run that "quit unexpectedly", and that dialog stood in front of the first screen
149
+ # (Lekhak TF-026). It is killed below only when it has not quit within 15 s.
150
+ if s.get("bundle_id") and s.get("app_path") and not s.get("stopped"):
151
+ exe_dir = os.path.join(s["app_path"], "Contents", "MacOS") + os.sep
152
+ def mac_running():
153
+ out = subprocess.run(["ps", "-A", "-ww", "-o", "command="], capture_output=True).stdout.decode(errors="replace")
154
+ return any(exe_dir in line for line in out.splitlines())
155
+ if mac_running():
156
+ bid = s["bundle_id"].replace('"', "")
157
+ try:
158
+ subprocess.run(["osascript", "-e", f'if application id "{bid}" is running then tell application id "{bid}" to quit'], capture_output=True, timeout=30)
159
+ except Exception:
160
+ pass
161
+ for _ in range(15):
162
+ if not mac_running():
163
+ break
164
+ time.sleep(1)
132
165
  for pid in s.get("pids", []):
133
166
  for sig in (signal.SIGTERM, signal.SIGKILL):
134
167
  try:
@@ -140,8 +173,23 @@ for pid in s.get("pids", []):
140
173
  pass
141
174
  time.sleep(1)
142
175
  image = s.get("win_image") or ""
143
- if image:
144
- subprocess.run(["taskkill.exe", "/F", "/T", "/IM", image], capture_output=True)
176
+ if s.get("win_pids"):
177
+ # this boot's own processes only (Sevak TF-003: /IM stopped every copy, another agent's included)
178
+ for wp in s["win_pids"]:
179
+ subprocess.run(["taskkill.exe", "/F", "/T", "/PID", str(wp)], capture_output=True)
180
+ elif image:
181
+ # a state written before win_pids: by name, as before, unless another start of it is still up
182
+ others = []
183
+ for f in os.listdir(d):
184
+ if f.startswith("boot-") and f.endswith(".json") and os.path.join(d, f) != path:
185
+ try:
186
+ o = json.load(open(os.path.join(d, f)))
187
+ if o.get("win_image") == image and not o.get("stopped") and o.get("pids") != s.get("pids"):
188
+ others.append(f)
189
+ except Exception:
190
+ pass
191
+ if not others:
192
+ subprocess.run(["taskkill.exe", "/F", "/T", "/IM", image], capture_output=True)
145
193
  if s.get("win_port"):
146
194
  ps = f"Get-NetTCPConnection -LocalPort {s['win_port']} -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }}"
147
195
  subprocess.run(["powershell.exe", "-NoProfile", "-Command", ps], capture_output=True)
@@ -149,8 +197,12 @@ if s.get("win_port"):
149
197
  # port and its files. The published copy names it: stop whatever runs from run-<port>/ (TF-043).
150
198
  m = re.search(r":(\d+)$", s.get("url") or "")
151
199
  run_port = port or (m.group(1) if m else "")
152
- if run_port:
153
- mark = os.path.join(os.getcwd(), d, f"run-{run_port}") + os.sep
200
+ marks = [os.path.join(os.getcwd(), d, f"run-{run_port}") + os.sep] if run_port else []
201
+ # A Mac Catalyst head: opening an Appium session starts the app again under a new process, so the
202
+ # pid written at boot is gone and the app is not; stop whatever runs from this boot's .app.
203
+ if s.get("app_path"):
204
+ marks.append(os.path.join(s["app_path"], "Contents", "MacOS") + os.sep)
205
+ if marks:
154
206
  try:
155
207
  procs = [p for p in os.listdir("/proc") if p.isdigit()]
156
208
  except Exception:
@@ -173,7 +225,7 @@ if run_port:
173
225
  if pid.isdigit():
174
226
  cmds.append((int(pid), cmd))
175
227
  for pid, cmd in cmds:
176
- if mark in cmd and pid != os.getpid():
228
+ if any(mk in cmd for mk in marks) and pid != os.getpid():
177
229
  try:
178
230
  os.kill(pid, signal.SIGKILL)
179
231
  except Exception:
@@ -194,7 +246,7 @@ print(f"STOPPED head={s.get('head')} mode={s.get('mode')} pids={s.get('pids')}")
194
246
  PY
195
247
  exit 0 ;;
196
248
  start) ;;
197
- *) sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 3 ;;
249
+ *) sed -n '2,39p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 3 ;;
198
250
  esac
199
251
 
200
252
  HEAD=""; PROJECT=""; PORT=""; CONFIG=""; STATIC=""; PROBE="/"; DRYRUN=0; ENVNAME=""
@@ -232,8 +284,10 @@ fi
232
284
 
233
285
  # ---- pick the project and the head ------------------------------------------------------
234
286
  tf_read_lines ALL < <(find_projects)
235
- WEB=(); WIN=()
236
- for p in ${ALL[@]+"${ALL[@]}"}; do is_web "$p" && WEB+=("$p"); is_maui_win "$p" && WIN+=("$p"); done
287
+ WEB=(); WIN=(); MAC=()
288
+ for p in ${ALL[@]+"${ALL[@]}"}; do is_web "$p" && WEB+=("$p"); is_maui_win "$p" && WIN+=("$p"); is_maui_mac "$p" && MAC+=("$p"); done
289
+ # a Mac Catalyst head is picked unasked only on a Mac, the one host that runs it
290
+ [[ "$PLATFORM" == "macos" ]] || MAC=()
237
291
  # Several web projects: the one that serves screens. The first in sorted order booted AppManager's
238
292
  # external API, which has no screens, in front of its admin site (AppManager TF-010). A project
239
293
  # serves screens when its own folder holds .razor or .cshtml pages, or it references a project
@@ -263,18 +317,26 @@ pick_web() { # sets PROJECT from WEB, or prints NONE and exits
263
317
  }
264
318
  if [[ -n "$PROJECT" ]]; then
265
319
  [[ -f "$PROJECT" ]] || { echo "NONE reason=--project $PROJECT does not exist"; exit 3; }
266
- if [[ -z "$HEAD" ]]; then is_web "$PROJECT" && HEAD=web; [[ -z "$HEAD" ]] && is_maui_win "$PROJECT" && HEAD=windows; fi
320
+ if [[ -z "$HEAD" ]]; then
321
+ is_web "$PROJECT" && HEAD=web
322
+ [[ -z "$HEAD" && "$PLATFORM" == "macos" ]] && is_maui_mac "$PROJECT" && HEAD=maccatalyst
323
+ [[ -z "$HEAD" ]] && is_maui_win "$PROJECT" && HEAD=windows
324
+ fi
267
325
  elif [[ -z "$HEAD" ]]; then
268
326
  if [[ ${#WEB[@]} -gt 0 ]]; then HEAD=web; pick_web
327
+ elif [[ ${#MAC[@]} -gt 0 ]]; then HEAD=maccatalyst; PROJECT="${MAC[0]}"
269
328
  elif [[ ${#WIN[@]} -gt 0 ]]; then HEAD=windows; PROJECT="${WIN[0]}"; fi
270
329
  else
271
- case "$HEAD" in web) pick_web ;; windows) PROJECT="${WIN[0]:-}" ;; esac
330
+ case "$HEAD" in web) pick_web ;; windows) PROJECT="${WIN[0]:-}" ;; maccatalyst) PROJECT="${MAC[0]:-}" ;; esac
272
331
  fi
273
332
  if [[ $DRYRUN -eq 1 ]]; then echo "PICK head=${HEAD:-none} project=${PROJECT:-none}"; exit 0; fi
274
333
  case "$HEAD" in
275
- android|ios|maccatalyst)
334
+ android|ios)
276
335
  write_state "$HEAD" none "" "" "" "${PROJECT:-}" "no driver for the $HEAD head ships in this framework version" no-driver "$PLATFORM"
277
336
  echo "NONE head=$HEAD reason=no driver for the $HEAD head ships in this framework version; its rows are recorded as not verified"; exit 2 ;;
337
+ maccatalyst)
338
+ [[ "$PLATFORM" == "macos" ]] || { write_state maccatalyst none "" "" "" "${PROJECT:-}" "a Mac Catalyst head runs only on a Mac" host "$PLATFORM"
339
+ echo "NONE head=maccatalyst kind=host reason=a Mac Catalyst head runs only on a Mac, this is $PLATFORM"; exit 2; } ;;
278
340
  web|windows) ;;
279
341
  "") write_state none none "" "" "" "" "no web project and no MAUI project with a windows target found under src/, source/ or the root" host "$PLATFORM"
280
342
  echo "NONE head=none reason=no web project (Microsoft.NET.Sdk.Web) and no MAUI project with a windows target found; name one with --project"; exit 2 ;;
@@ -285,6 +347,165 @@ PDIR="$(dirname "$PROJECT")"; PNAME="$(basename "$PROJECT" .csproj)"
285
347
  ASM="$(grep -oE '<AssemblyName>[^<]+' "$PROJECT" 2>/dev/null | head -1 | sed 's/<AssemblyName>//')"; ASM="${ASM:-$PNAME}"
286
348
  CFGARGS=(); [[ -n "$CONFIG" ]] && CFGARGS=(-c "$CONFIG")
287
349
 
350
+ # ---- maccatalyst (MAUI on this Mac, reached over Appium's mac2 driver) -----------------------
351
+ if [[ "$HEAD" == "maccatalyst" ]]; then
352
+ # A Blazor Hybrid head draws its screens in a web view, and on a Mac neither data-testid nor an
353
+ # HTML id reaches mac2 or the macOS accessibility tree (probed 2026-10-07). It is driven all the
354
+ # same, without control names (owner decision A): the state says webview, the screen check skips
355
+ # the name check, and the verdict writes it as not measured.
356
+ WEBVIEW=0; grep -q 'Microsoft\.AspNetCore\.Components\.WebView\.Maui' "$PROJECT" && WEBVIEW=1
357
+ TFM="$(grep -oE 'net[0-9.]+-maccatalyst[0-9.]*' "$PROJECT" | head -1)"
358
+ # the endpoint the app registers (an uncommented maccatalyst: entry with a url), else this Mac
359
+ AURL="$(python3 - <<'PY'
360
+ import re
361
+ try:
362
+ t = open(".tfcore/core-config.yaml", encoding="utf-8").read()
363
+ except Exception:
364
+ t = ""
365
+ m = re.search(r"(?m)^[ \t]+maccatalyst:[ \t]*(?:\{[^}\n]*url:[ \t]*([^,}\s]+)|\n[ \t]+url:[ \t]*(\S+))", t)
366
+ print((m.group(1) or m.group(2)) if m else "")
367
+ PY
368
+ )"
369
+ AURL="${AURL:-http://localhost:4723}"; AURL="${AURL%/}"
370
+ APORT="${AURL##*:}"; [[ "$APORT" =~ ^[0-9]+$ ]] || APORT=4723
371
+ keyed "$APORT"
372
+ mac_none() { # reason: a host fault found before anything was started
373
+ write_state maccatalyst none "" "" "" "$PROJECT" "$1" host "$PLATFORM"
374
+ echo "NONE head=maccatalyst kind=host reason=$1"; exit 2; }
375
+ # Lekhak TF-026: two set-up faults of a fresh macOS 27 / Xcode 27 machine, each of which stopped every
376
+ # mac2 session with an error that does not name its fix. Checked here, before a build of minutes.
377
+ # 1. mac2 turns Automation Mode on for each session; when that needs a password it never comes, and
378
+ # the session fails with "Timed out while enabling automation mode".
379
+ if command -v automationmodetool >/dev/null 2>&1 && automationmodetool 2>&1 | grep -q '^This device REQUIRES user authentication'; then
380
+ mac_none "macOS asks for a password before Automation Mode comes on, so no mac2 session can open. Run once, with an administrator password: sudo automationmodetool enable-automationmode-without-authentication"
381
+ fi
382
+ # 2. mac2 builds its WebDriverAgentMac with the Xcode on this Mac. Before 4.1.1 that build fails
383
+ # under Xcode 27 (deployment target 10.15 below its 12.0 minimum, xcodebuild exit 65). An
384
+ # `appium driver update mac2` left the driver unloadable ("Cannot find package 'appium'");
385
+ # uninstalling and installing again is what worked.
386
+ if command -v appium >/dev/null 2>&1; then
387
+ M2="$(appium driver list --installed --json 2>/dev/null | python3 -c 'import json,sys
388
+ try: print(json.load(sys.stdin).get("mac2", {}).get("version", ""))
389
+ except Exception: print("?")' 2>/dev/null)"
390
+ XMAJ="$(xcodebuild -version 2>/dev/null | sed -n 's/^Xcode \([0-9]*\).*/\1/p' | head -1)"
391
+ REINSTALL="appium driver uninstall mac2; appium driver install mac2"
392
+ if [[ -z "$M2" ]]; then
393
+ mac_none "Appium's mac2 driver is not installed. Run: appium driver install mac2"
394
+ elif [[ "$M2" != "?" && "${XMAJ:-0}" -ge 27 ]] && python3 -c 'import sys
395
+ v = tuple(int(x) for x in sys.argv[1].split("-")[0].split(".")[:3])
396
+ sys.exit(0 if v < (4, 1, 1) else 1)' "$M2"; then
397
+ mac_none "the mac2 driver $M2 cannot build its helper with Xcode $XMAJ (4.1.1 is the first that can). Run: $REINSTALL"
398
+ fi
399
+ fi
400
+ ready() { curl -s -m 3 "$AURL/status" 2>/dev/null | grep -q '"ready":true'; }
401
+ APPIUM_PID=""
402
+ if ! ready; then
403
+ # boot it yourself: a local Appium that is down is started here and stopped by `stop`
404
+ if [[ "$AURL" =~ ^http://(localhost|127\.0\.0\.1): ]] && command -v appium >/dev/null 2>&1; then
405
+ nohup appium --address 127.0.0.1 --port "$APORT" > "$DIR/appium-$APORT.log" 2>&1 < /dev/null &
406
+ APPIUM_PID=$!; i=0
407
+ while [[ $i -lt 60 ]] && ! ready; do sleep 2; i=$((i+2)); done
408
+ fi
409
+ if ! ready; then
410
+ [[ -n "$APPIUM_PID" ]] && kill "$APPIUM_PID" 2>/dev/null
411
+ write_state maccatalyst none "" "" "" "$PROJECT" "Appium did not answer at $AURL/status" host "$PLATFORM"
412
+ echo "NONE head=maccatalyst kind=host reason=Appium did not answer at $AURL/status$([[ -z "$APPIUM_PID" ]] && ! command -v appium >/dev/null 2>&1 && echo '; appium is not installed (docs/TechieFlow-Setup.md §0b step 3)')"; exit 2
413
+ fi
414
+ fi
415
+ stop_appium() { [[ -n "$APPIUM_PID" ]] && kill "$APPIUM_PID" 2>/dev/null; }
416
+ # the Mac rules first (coding-standards-dotnet.md §9): a missing scene manifest is stopped here,
417
+ # before a build that macOS 27 would end at the first window; a WARN is printed and the boot goes on
418
+ chk="$(bash "$HERE/tf-maccatalyst-check.sh" "$PROJECT" 2>&1)"; crc=$?
419
+ printf '%s\n' "$chk" >> "$LOG"; grep -E '^WARN' <<<"$chk" >&2
420
+ OSMAJOR="$(sw_vers -productVersion 2>/dev/null | cut -d. -f1)"
421
+ if [[ $crc -eq 1 && "${OSMAJOR:-0}" -ge 27 ]]; then
422
+ stop_appium; first="$(grep -m1 -E '^FAIL' <<<"$chk" | sed -E 's/^FAIL [^:]+: //')"
423
+ write_state maccatalyst none "" "" "tf-maccatalyst-check.sh" "$PROJECT" "$first" build-error "$PLATFORM"
424
+ echo "NONE head=maccatalyst kind=build-error reason=$first (bash .tfcore/utils/tf-maccatalyst-check.sh)"; exit 2
425
+ fi
426
+ [[ $crc -eq 1 ]] && grep -E '^FAIL' <<<"$chk" | sed 's/^FAIL/WARN (fatal from macOS 27)/' >&2
427
+ BARGS=(-f "$TFM" -c "${CONFIG:-Debug}")
428
+ echo "### build $PROJECT -f $TFM" >> "$LOG"
429
+ out="$(bash "$HERE/tf-build.sh" build "$PROJECT" -- "${BARGS[@]}" 2>&1)"; brc=$?
430
+ # A Mac Catalyst SDK names the one Xcode it was made for and refuses a newer one; tf-build.sh builds
431
+ # again with the check off and says so in a note line (Lekhak TF-032), which the state carries.
432
+ XNOTE="$(grep -oE 'requires Xcode [0-9.]+[.] The current version of Xcode is [0-9.]+' <<<"$(grep '^note' <<<"$out")" | head -1)"
433
+ printf '%s\n' "$out" >> "$LOG"
434
+ verdict="$(grep -E '^(PASS|FAIL|NOT-RUN)' <<<"$out" | tail -1)"
435
+ if [[ $brc -ne 0 ]]; then
436
+ stop_appium
437
+ kind=host; [[ $brc -eq 1 ]] && kind=build-error
438
+ write_state maccatalyst none "" "" "tf-build.sh build -f $TFM" "$PROJECT" "${verdict:-the build failed}" "$kind" "$PLATFORM"
439
+ echo "NONE head=maccatalyst kind=$kind reason=${verdict:-the build failed} (log $LOG)"; exit 2
440
+ fi
441
+ APP="$(ls -td "$PDIR/bin/${CONFIG:-Debug}/$TFM"/*.app "$PDIR/bin/${CONFIG:-Debug}/$TFM"/*/*.app 2>/dev/null | head -1)"
442
+ if [[ -z "$APP" ]]; then
443
+ stop_appium; write_state maccatalyst none "" "" "tf-build.sh build -f $TFM" "$PROJECT" "the build wrote no .app under $PDIR/bin/${CONFIG:-Debug}/$TFM" host "$PLATFORM"
444
+ echo "NONE head=maccatalyst kind=host reason=the build wrote no .app under $PDIR/bin/${CONFIG:-Debug}/$TFM (log $LOG)"; exit 2
445
+ fi
446
+ APP="$(cd "$APP" && pwd)"
447
+ BUNDLE="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP/Contents/Info.plist" 2>/dev/null)"
448
+ EXE="$(/usr/libexec/PlistBuddy -c 'Print CFBundleExecutable' "$APP/Contents/Info.plist" 2>/dev/null)"
449
+ if OLD="$(pgrep -f "$APP/Contents/MacOS/$EXE" | head -1)" && [[ -n "$OLD" ]]; then
450
+ stop_appium; write_state maccatalyst none "" "" "" "$PROJECT" "another copy of $EXE is already running (pid $OLD)" host "$PLATFORM"
451
+ echo "NONE head=maccatalyst kind=host reason=another copy of $EXE is already running (pid $OLD), and mac2 would attach to either; stop it first"; exit 2
452
+ fi
453
+ MARK="$DIR/.launch-$APORT"; : > "$MARK"; ROOT_LOG="$PWD/$LOG"
454
+ # Opened the way Finder opens it: macOS kills a sandboxed app started from its binary directly
455
+ # ("Killed: 9", Lekhak BlogAdmin, 2026-10-07). Its output goes to the log through open's own options.
456
+ # -ApplePersistenceIgnoreState: never offer to reopen the windows of an earlier run (Lekhak TF-026)
457
+ OPENERR="$(open -n -o "$ROOT_LOG" --stderr "$ROOT_LOG" "$APP" --args -ApplePersistenceIgnoreState YES 2>&1)"; printf '%s\n' "$OPENERR" >> "$LOG"
458
+ APP_PID=""; i=0
459
+ while [[ $i -lt 10 && -z "$APP_PID" ]]; do sleep 1; i=$((i+1)); APP_PID="$(pgrep -f "$APP/Contents/MacOS/$EXE" | head -1)"; done
460
+ i=0; while [[ -n "$APP_PID" && $i -lt 8 ]] && kill -0 "$APP_PID" 2>/dev/null; do sleep 1; i=$((i+1)); done
461
+ if [[ -z "$APP_PID" ]] || ! kill -0 "$APP_PID" 2>/dev/null; then
462
+ stop_appium
463
+ crash="$(find "$HOME/Library/Logs/DiagnosticReports" -name "$EXE-*.ips" -newer "$MARK" 2>/dev/null | head -1)"
464
+ hint=""; grep -qs NoSceneLifecycleAdoption "$crash" && hint="; macOS stops a UIKit app that has not adopted scenes: add UIApplicationSceneManifest to Platforms/MacCatalyst/Info.plist and a SceneDelegate : MauiUISceneDelegate"
465
+ # macOS refused to start it at all (Lekhak BlogAdmin, 2026-10-07: "Launch failed", POSIX 163): a
466
+ # locally signed build that asks for keychain-access-groups, which needs a provisioning profile
467
+ if grep -q 'Launch failed' <<<"$OPENERR"; then
468
+ hint="; macOS refused to start it ($(grep -oE 'Code=[0-9]+ "[^"]*"' <<<"$OPENERR" | tail -1))"
469
+ codesign -d --entitlements :- "$APP" 2>/dev/null | grep -q keychain-access-groups && hint="$hint: the build is signed locally (ad hoc) and asks for keychain-access-groups, which needs a provisioning profile; leave that entitlement out of Debug builds or set CodesignProvisioningProfile"
470
+ fi
471
+ write_state maccatalyst none "" "" "tf-build.sh build -f $TFM" "$PROJECT" "the app quit on start${crash:+ (crash report $crash)}$hint" build-error "$PLATFORM"
472
+ echo "NONE head=maccatalyst kind=build-error reason=the app quit on start${crash:+ (crash report $crash)}$hint (log $LOG)"; exit 2
473
+ fi
474
+ write_state maccatalyst appium "$AURL" "$APP_PID $APPIUM_PID" "tf-build.sh build -f $TFM" "$PROJECT" "" "" "$PLATFORM"
475
+ set_state bundle_id "$(json_escape "$BUNDLE")"
476
+ set_state app_path "$(json_escape "$APP")"
477
+ # Lekhak TF-026: a running process and an answering Appium are not a boot. A mac2 session has to open
478
+ # and find the app's own window on view, not a system dialog, before this says BOOTED.
479
+ SHOT="$PWD/$DIR/boot-$APORT-window.png"
480
+ FIRST="$(TF_A="$AURL" TF_B="$BUNDLE" TF_P="$APP" TF_S="$SHOT" node --input-type=module -e "
481
+ import { firstScreen } from '$HERE/tf-appium.mjs';
482
+ const r = await firstScreen(process.env.TF_A, { bundleId: process.env.TF_B, appPath: process.env.TF_P, shot: process.env.TF_S });
483
+ console.log(JSON.stringify(r));" 2>&1 | tail -1)"
484
+ printf '### first screen: %s\n' "$FIRST" >> "$LOG"
485
+ fs() { python3 -c 'import json,sys
486
+ try: print(json.loads(sys.argv[1]).get(sys.argv[2]) or "")
487
+ except Exception: print("")' "$FIRST" "$1"; }
488
+ if [[ "$(fs ok)" != "True" ]]; then
489
+ why="$(fs reason)"; why="${why:-$(cut -c1-200 <<<"$FIRST")}"
490
+ fix=""
491
+ case "$why" in
492
+ *[Aa]utomation\ mode*) fix="; run once, with an administrator password: sudo automationmodetool enable-automationmode-without-authentication" ;;
493
+ *"Cannot find package"*|*"Could not find a driver"*|*xcodebuild*|*"code 65"*) fix="; the mac2 driver does not load or cannot build its helper: appium driver uninstall mac2; appium driver install mac2" ;;
494
+ esac
495
+ [[ "$(fs stage)" == session && -z "$fix" ]] && fix="; a mac2 session that cannot start is usually a permission: System Settings > Privacy & Security > Accessibility (the terminal, and WebDriverAgentRunner-Runner)"
496
+ bash "${BASH_SOURCE[0]}" stop --port "$APORT" >/dev/null 2>&1
497
+ write_state maccatalyst none "" "" "tf-build.sh build -f $TFM" "$PROJECT" "the app started but its first screen was not reached: $why$fix" host "$PLATFORM"
498
+ echo "NONE head=maccatalyst kind=host reason=the app started but its first screen was not reached: $why$fix (log $LOG)"; exit 2
499
+ fi
500
+ set_state first_screen "$(json_escape "$SHOT")"
501
+ DISMISSED="$(fs dismissed)"
502
+ [[ -n "$DISMISSED" ]] && { set_state dismissed "$(json_escape "$DISMISSED")"; echo "tf-verify-boot: $DISMISSED" >&2; }
503
+ [[ -n "$XNOTE" ]] && set_state xcode_check "$(json_escape "off: $XNOTE")"
504
+ [[ $WEBVIEW -eq 1 ]] && set_state webview true
505
+ echo "BOOTED head=maccatalyst mode=appium url=$AURL bundle=$BUNDLE project=$PROJECT tfm=$TFM$([[ $WEBVIEW -eq 1 ]] && echo ' webview=yes (control names not measured)') pid=$APP_PID${APPIUM_PID:+ appium-pid=$APPIUM_PID}${XNOTE:+ xcode-check=off ($XNOTE)} window=$DIR/boot-$APORT-window.png log=$LOG stop=\"bash .tfcore/utils/tf-verify-boot.sh stop --port $APORT\""
506
+ exit 0
507
+ fi
508
+
288
509
  # ---- web ------------------------------------------------------------------------------
289
510
  if [[ "$HEAD" == "web" ]]; then
290
511
  if [[ -z "$PORT" ]]; then
@@ -432,10 +653,41 @@ fi
432
653
  [[ "$PLATFORM" == "wsl" || "$PLATFORM" == "windows" ]] || { write_state windows none "" "" "" "$PROJECT" "a Windows head runs only from WSL or Windows" host "$PLATFORM"; echo "NONE head=windows reason=a Windows head runs only from WSL or Windows, this is $PLATFORM"; exit 2; }
433
654
  command -v cmd.exe >/dev/null 2>&1 || { write_state windows none "" "" "" "$PROJECT" "cmd.exe not reachable" host "$PLATFORM"; echo "NONE head=windows reason=cmd.exe is not reachable from this shell"; exit 2; }
434
655
  TFM="$(grep -oE 'net[0-9.]+-windows[0-9.]*' "$PROJECT" | head -1)"
435
- CDP_PORT=9222; RELAY_PORT="${PORT:-9223}"; keyed "$RELAY_PORT"
656
+ RELAY_PORT="${PORT:-9223}"; keyed "$RELAY_PORT"
657
+ # Each boot drives its own embedded browser (Sevak TF-003). The DevTools port was 9222 for every
658
+ # head, so with two up the relay on 9223 listed pages of both apps and a smoke drove the other
659
+ # agent's window. The port now follows the relay port (9223 keeps 9222). A second head while one is
660
+ # up also gets its own WebView2 data folder: two copies of one app would otherwise share one browser
661
+ # process, whose single DevTools port lists both.
662
+ if [[ "$RELAY_PORT" == "9223" ]]; then CDP_PORT=9222; else CDP_PORT=$((RELAY_PORT + 20000)); fi
663
+ OTHERS="$(python3 - "$DIR" "$RELAY_PORT" <<'PY'
664
+ import glob, json, os, sys
665
+ n = 0
666
+ for f in glob.glob(os.path.join(sys.argv[1], "boot-*.json")):
667
+ if f.endswith(f"boot-{sys.argv[2]}.json"):
668
+ continue
669
+ try:
670
+ s = json.load(open(f))
671
+ except Exception:
672
+ continue
673
+ if s.get("head") == "windows" and s.get("mode") == "cdp" and not s.get("stopped"):
674
+ n += 1
675
+ print(n)
676
+ PY
677
+ )"
678
+ UDF_SET=""
679
+ if [[ "${OTHERS:-0}" -gt 0 ]]; then
680
+ UDF="$DIR/webview-$RELAY_PORT"; mkdir -p "$UDF"
681
+ UDF_SET="set WEBVIEW2_USER_DATA_FOLDER=$(wslpath -w "$UDF")&& "
682
+ echo "tf-verify-boot: $OTHERS other Windows head(s) up — this one gets DevTools port $CDP_PORT and its own WebView2 data folder ($UDF), so neither sees the other's pages" >&2
683
+ fi
436
684
  WP="$(winarg "$(wslpath -w "$PROJECT")")"; RELAY="$(wslpath -w "$HERE/tf-cdp-relay.ps1")"
437
- echo "### windows head: $PROJECT -f $TFM" >> "$LOG"
438
- nohup cmd.exe /c "set WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=$CDP_PORT&& dotnet run --project $WP -f $TFM ${CFGARGS[*]:-}" >> "$LOG" 2>&1 < /dev/null &
685
+ echo "### windows head: $PROJECT -f $TFM (DevTools $CDP_PORT, relay $RELAY_PORT)" >> "$LOG"
686
+ # this start's own Windows processes: the app's ids before it, so the new ones are this boot's and
687
+ # stop kills them alone, never every copy of the program (taskkill /IM stopped another agent's app)
688
+ win_ids() { powershell.exe -NoProfile -Command "Get-Process -Name '$ASM' -ErrorAction SilentlyContinue | ForEach-Object { \$_.Id }" 2>/dev/null | tr -d '\r' | grep -E '^[0-9]+$' | sort; }
689
+ BEFORE_IDS="$(win_ids)"
690
+ nohup cmd.exe /c "${UDF_SET}set WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=$CDP_PORT&& dotnet run --project $WP -f $TFM ${CFGARGS[*]:-}" >> "$LOG" 2>&1 < /dev/null &
439
691
  APP_PID=$!
440
692
  nohup powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$RELAY" -ListenPort "$RELAY_PORT" -TargetPort "$CDP_PORT" >> "$DIR/relay.log" 2>&1 < /dev/null &
441
693
  RELAY_PID=$!
@@ -458,13 +710,16 @@ while [[ $i -lt 300 && -z "$URL" ]]; do
458
710
  fi
459
711
  sleep 3; i=$((i+3))
460
712
  done
713
+ MINE="$(comm -13 <(printf '%s\n' "$BEFORE_IDS") <(win_ids) | tr '\n' ' ')"
461
714
  if [[ -z "$URL" ]]; then
462
715
  pkill -P "$APP_PID" 2>/dev/null; kill "$APP_PID" "$RELAY_PID" 2>/dev/null
463
- taskkill.exe /F /T /IM "$ASM.exe" >/dev/null 2>&1
716
+ for w in $MINE; do taskkill.exe /F /T /PID "$w" >/dev/null 2>&1; done
464
717
  write_state windows none "" "" "cmd.exe /c dotnet run -f $TFM" "$PROJECT" "the DevTools port $RELAY_PORT never answered within 300 s" host "$PLATFORM"
465
718
  echo "NONE head=windows kind=host reason=the app's DevTools port never answered on ${HOSTS[*]}:$RELAY_PORT within 300 s (log $LOG, relay $DIR/relay.log)"; exit 2
466
719
  fi
467
720
  write_state windows cdp "$URL" "$APP_PID $RELAY_PID" "cmd.exe /c dotnet run -f $TFM" "$PROJECT" "" "" "$PLATFORM"
468
721
  set_state win_image "\"$ASM.exe\""
722
+ set_state win_pids "[$(tr ' ' '\n' <<<"$MINE" | grep -E '^[0-9]+$' | paste -sd, -)]"
723
+ set_state cdp_port "$CDP_PORT"
469
724
  echo "BOOTED head=windows mode=cdp url=$URL project=$PROJECT tfm=$TFM pids=$APP_PID,$RELAY_PID image=$ASM.exe log=$LOG"
470
725
  exit 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
@@ -59,6 +61,30 @@ if own and run_id and own > run_id:
59
61
  print(f"tf-verify-emit: {run_id} is when {caller} started; this verify started {own} and is recorded from then")
60
62
  run_id = own
61
63
 
64
+ def q(*args):
65
+ p = subprocess.run(["bash", EMIT, *args], capture_output=True, text=True)
66
+ return p.stdout.strip()
67
+
68
+ runs_path = os.path.join(q("--where") or "docs/metrics", "runs.jsonl")
69
+ runs = []
70
+ if os.path.isfile(runs_path):
71
+ for line in open(runs_path, encoding="utf-8", errors="replace"):
72
+ try:
73
+ runs.append(json.loads(line))
74
+ except Exception:
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
+
62
88
  def emit(stream, rec):
63
89
  p = subprocess.run(["bash", EMIT, stream], input=json.dumps(rec), capture_output=True, text=True)
64
90
  msg = (p.stdout + p.stderr).strip()
@@ -67,24 +93,14 @@ def emit(stream, rec):
67
93
  return False
68
94
  return True
69
95
 
70
- def q(*args):
71
- p = subprocess.run(["bash", EMIT, *args], capture_output=True, text=True)
72
- return p.stdout.strip()
73
-
74
96
  # origin lookup: the last build-phase or fix-issues run that touched the row
75
- runs_path = os.path.join(q("--where") or "docs/metrics", "runs.jsonl")
76
97
  origin = {}
77
- if os.path.isfile(runs_path):
78
- for line in open(runs_path, encoding="utf-8", errors="replace"):
79
- try:
80
- r = json.loads(line)
81
- except Exception:
82
- continue
83
- if r.get("kind") != "run" or r.get("backfilled") or r.get("cmd") not in ("build-phase", "fix-issues"):
84
- continue
85
- for rid in r.get("reqs_touched") or []:
86
- if r.get("started", "") >= origin.get(rid, ("", ""))[0]:
87
- origin[rid] = (r.get("started", ""), r.get("cmd"), (r.get("subagents") or ["flow-master"])[0] if r.get("subagents") else "flow-master")
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")
88
104
 
89
105
  gates = misses = 0
90
106
  for row in v["rows"]:
@@ -95,6 +111,8 @@ for row in v["rows"]:
95
111
  rec = {"kind": "gate", "app": app, "run_id": run_id, "req_id": rid, "req_class": row["class"],
96
112
  "attempt": int(att) if att.isdigit() else 1, "verdict": row["status"], "gate": row["gate"],
97
113
  "gates_run": row["gates_run"], "failure_class": row["failure_class"], "prior_verdict": row["prior_verdict"]}
114
+ if phase:
115
+ rec["app_phase"] = phase
98
116
  if emit("gates", rec):
99
117
  gates += 1
100
118
  if not row["gate"]:
@@ -124,7 +142,9 @@ rec = {"kind": "run", "app": app, "cmd": "verify-phase", "mode": mode, "yolo": y
124
142
  "build_result": v.get("build_result", "not-run")}
125
143
  if run_id:
126
144
  rec["started"] = run_id
145
+ if phase:
146
+ rec["app_phase"] = phase
127
147
  ok = emit("runs", rec)
128
- 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()})")
129
149
  PY
130
150
  exit 0