@techierathore/techieflow 1.0.2 → 1.0.4

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 (204) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +5 -13
  2. package/.claude/commands/TechieFlow/agents/architect.md +1 -16
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +42 -215
  4. package/.claude/commands/TechieFlow/agents/verifier.md +24 -64
  5. package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +24 -117
  6. package/.claude/commands/TechieFlow/tasks/_owner-language.md +26 -0
  7. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +20 -84
  8. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +10 -74
  9. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +20 -58
  10. package/.claude/commands/TechieFlow/tasks/amend-docs.md +21 -143
  11. package/.claude/commands/TechieFlow/tasks/build-phase.md +14 -224
  12. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
  13. package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
  14. package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
  15. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +31 -505
  16. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +28 -213
  17. package/.claude/commands/TechieFlow/tasks/deploy-checklist.md +19 -0
  18. package/.claude/commands/TechieFlow/tasks/devguide.md +10 -219
  19. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
  20. package/.claude/commands/TechieFlow/tasks/fix-issues.md +10 -129
  21. package/.claude/commands/TechieFlow/tasks/generate-html.md +5 -1
  22. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +18 -16
  23. package/.claude/commands/TechieFlow/tasks/log-miss.md +19 -158
  24. package/.claude/commands/TechieFlow/tasks/metrics-report.md +3 -1
  25. package/.claude/commands/TechieFlow/tasks/mockups.md +12 -117
  26. package/.claude/commands/TechieFlow/tasks/refresh-status.md +9 -130
  27. package/.claude/commands/TechieFlow/tasks/split-brd.md +10 -126
  28. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +14 -0
  29. package/.claude/commands/TechieFlow/tasks/triage-issues.md +11 -152
  30. package/.claude/commands/TechieFlow/tasks/verify-phase.md +36 -572
  31. package/.opencode/plugin/techieflow.js +40 -13
  32. package/.tfcore/agents/analyst.md +5 -13
  33. package/.tfcore/agents/architect.md +1 -16
  34. package/.tfcore/agents/flow-master.md +42 -215
  35. package/.tfcore/agents/verifier.md +24 -64
  36. package/.tfcore/core-config.yaml +10 -0
  37. package/.tfcore/hooks/block-git.sh +4 -4
  38. package/.tfcore/hooks/guard-artifacts.sh +2 -2
  39. package/.tfcore/hooks/guard-build.sh +76 -0
  40. package/.tfcore/hooks/guard-db.sh +90 -0
  41. package/.tfcore/hooks/guard-metrics.sh +68 -0
  42. package/.tfcore/hooks/guard-status-html.sh +116 -71
  43. package/.tfcore/hooks/guard-status.sh +43 -16
  44. package/.tfcore/hooks/guard-verify-deps.sh +124 -0
  45. package/.tfcore/hooks/guard-verify.sh +70 -48
  46. package/.tfcore/hooks/sweep-artifacts.sh +2 -2
  47. package/.tfcore/install-manifest.yaml +0 -18
  48. package/.tfcore/routing.yaml +86 -14
  49. package/.tfcore/standards/coding-standards-core.md +61 -0
  50. package/.tfcore/standards/coding-standards-dotnet.md +93 -0
  51. package/.tfcore/tasks/_metrics-emit-gate.md +24 -117
  52. package/.tfcore/tasks/_owner-language.md +26 -0
  53. package/.tfcore/tasks/_smoke-test-policy.md +20 -84
  54. package/.tfcore/tasks/_status-update-gate.md +10 -74
  55. package/.tfcore/tasks/_yolo-mode.md +20 -58
  56. package/.tfcore/tasks/amend-docs.md +21 -143
  57. package/.tfcore/tasks/build-phase.md +14 -224
  58. package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
  59. package/.tfcore/tasks/create-doc.md +4 -0
  60. package/.tfcore/tasks/create-project-brief.md +22 -0
  61. package/.tfcore/tasks/day1-brownfield.md +31 -505
  62. package/.tfcore/tasks/day1-greenfield.md +28 -213
  63. package/.tfcore/tasks/deploy-checklist.md +19 -0
  64. package/.tfcore/tasks/devguide.md +10 -219
  65. package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
  66. package/.tfcore/tasks/fix-issues.md +10 -129
  67. package/.tfcore/tasks/generate-html.md +5 -1
  68. package/.tfcore/tasks/handoff-phase.md +18 -16
  69. package/.tfcore/tasks/log-miss.md +19 -158
  70. package/.tfcore/tasks/metrics-report.md +3 -1
  71. package/.tfcore/tasks/mockups.md +12 -117
  72. package/.tfcore/tasks/refresh-status.md +9 -130
  73. package/.tfcore/tasks/split-brd.md +10 -126
  74. package/.tfcore/tasks/triage-and-fix.md +14 -0
  75. package/.tfcore/tasks/triage-issues.md +11 -152
  76. package/.tfcore/tasks/verify-phase.md +36 -572
  77. package/.tfcore/telemetry/SCHEMA.md +100 -8
  78. package/.tfcore/telemetry/tf-metrics.sh +564 -32
  79. package/.tfcore/templates/stack-defaults/dotnet.md +35 -0
  80. package/.tfcore/templates/stack-questions.md +77 -0
  81. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +3 -4
  82. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +107 -115
  83. package/.tfcore/templates/v4custom/app-brd-tmpl.md +108 -184
  84. package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
  85. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +48 -73
  86. package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +1 -1
  87. package/.tfcore/templates/v4custom/app-coding-standards-tmpl.md +52 -0
  88. package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
  89. package/.tfcore/templates/v4custom/app-deployment-checklist-tmpl.md +86 -0
  90. package/.tfcore/templates/v4custom/app-devguide-tmpl.md +69 -146
  91. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
  92. package/.tfcore/templates/v4custom/app-phases-tmpl.md +35 -0
  93. package/.tfcore/templates/v4custom/app-productguide-tmpl.md +38 -35
  94. package/.tfcore/templates/v4custom/app-project-status-tmpl.md +63 -73
  95. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +62 -37
  96. package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +69 -54
  97. package/.tfcore/templates/v4custom/build-invocation-ladder.md +17 -180
  98. package/.tfcore/templates/v4custom/build-subagent-prompt.md +17 -0
  99. package/.tfcore/templates/v4custom/html-render-shell.md +1 -1
  100. package/.tfcore/templates/v4custom/metrics-report-template.md +2 -2
  101. package/.tfcore/user-guide.md +0 -23
  102. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  103. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  104. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  105. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  106. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  107. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  108. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  109. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  110. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  111. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  112. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  113. package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
  114. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  115. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  116. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  117. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  118. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  119. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  120. package/.tfcore/utils/tf-brd-status.py +176 -0
  121. package/.tfcore/utils/tf-brd-status.sh +19 -0
  122. package/.tfcore/utils/tf-build-list.py +211 -0
  123. package/.tfcore/utils/tf-build-list.sh +21 -0
  124. package/.tfcore/utils/tf-build.sh +143 -0
  125. package/.tfcore/utils/tf-cdp-relay.ps1 +64 -0
  126. package/.tfcore/utils/tf-checklist-edit.py +133 -0
  127. package/.tfcore/utils/tf-day1-files.py +208 -0
  128. package/.tfcore/utils/tf-day1-files.sh +20 -0
  129. package/.tfcore/utils/tf-devguide-list.py +249 -0
  130. package/.tfcore/utils/tf-devguide-list.sh +18 -0
  131. package/.tfcore/utils/tf-doc-check.py +1369 -0
  132. package/.tfcore/utils/tf-doc-check.sh +29 -0
  133. package/.tfcore/utils/tf-emit.sh +545 -18
  134. package/.tfcore/utils/tf-fix-close.sh +115 -0
  135. package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
  136. package/.tfcore/utils/tf-goal.sh +284 -66
  137. package/.tfcore/utils/tf-harness.sh +4 -9
  138. package/.tfcore/utils/tf-log-miss.py +197 -0
  139. package/.tfcore/utils/tf-log-miss.sh +14 -0
  140. package/.tfcore/utils/tf-misses-md.py +208 -0
  141. package/.tfcore/utils/tf-misses-md.sh +14 -0
  142. package/.tfcore/utils/tf-mockups-locate.py +66 -0
  143. package/.tfcore/utils/tf-mockups-locate.sh +18 -0
  144. package/.tfcore/utils/tf-model-pick.sh +475 -0
  145. package/.tfcore/utils/tf-opencode-audit.py +23 -7
  146. package/.tfcore/utils/tf-perf-grade.py +97 -0
  147. package/.tfcore/utils/tf-perf-grade.sh +12 -0
  148. package/.tfcore/utils/tf-phase.sh +92 -0
  149. package/.tfcore/utils/tf-render-html.py +20 -0
  150. package/.tfcore/utils/tf-routing-bind.sh +97 -6
  151. package/.tfcore/utils/tf-routing.sh +13 -7
  152. package/.tfcore/utils/tf-selfcheck.sh +323 -0
  153. package/.tfcore/utils/tf-split-brd.py +309 -0
  154. package/.tfcore/utils/tf-split-brd.sh +22 -0
  155. package/.tfcore/utils/tf-status-evidence.sh +68 -0
  156. package/.tfcore/utils/tf-status-facts.py +358 -0
  157. package/.tfcore/utils/tf-status-facts.sh +20 -0
  158. package/.tfcore/utils/tf-triage.py +243 -0
  159. package/.tfcore/utils/tf-triage.sh +16 -0
  160. package/.tfcore/utils/tf-verify-boot.sh +250 -0
  161. package/.tfcore/utils/tf-verify-emit.sh +110 -0
  162. package/.tfcore/utils/tf-verify-env.sh +88 -0
  163. package/.tfcore/utils/tf-verify-list.py +270 -0
  164. package/.tfcore/utils/tf-verify-list.sh +13 -0
  165. package/.tfcore/utils/tf-verify-screens.mjs +355 -0
  166. package/.tfcore/utils/tf-verify-screens.sh +31 -0
  167. package/.tfcore/utils/tf-verify-tests.sh +166 -0
  168. package/.tfcore/utils/tf-verify-verdict.py +292 -0
  169. package/.tfcore/utils/tf-verify-verdict.sh +15 -0
  170. package/.tfcore/utils/tf-yolo.sh +59 -0
  171. package/README.md +120 -1145
  172. package/docs/TechieFlow-Installation.md +1 -5
  173. package/opencode.jsonc +37 -33
  174. package/package.json +1 -5
  175. package/scripts/install.mjs +56 -46
  176. package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +0 -117
  177. package/.claude/commands/TechieFlow/tasks/author-brd.md +0 -79
  178. package/.claude/commands/TechieFlow/tasks/document-project.md +0 -343
  179. package/.claude/commands/TechieFlow/tasks/execute-checklist.md +0 -86
  180. package/.claude/commands/TechieFlow/tasks/index-docs.md +0 -173
  181. package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +0 -75
  182. package/.claude/commands/TechieFlow/tasks/shard-doc.md +0 -185
  183. package/.codex/config.toml +0 -46
  184. package/.codex/hooks.json +0 -63
  185. package/.codex/rules/techieflow.rules +0 -8
  186. package/.tfcore/hooks/codex-adapter.py +0 -248
  187. package/.tfcore/tasks/advanced-elicitation.md +0 -117
  188. package/.tfcore/tasks/author-brd.md +0 -79
  189. package/.tfcore/tasks/document-project.md +0 -343
  190. package/.tfcore/tasks/execute-checklist.md +0 -86
  191. package/.tfcore/tasks/index-docs.md +0 -173
  192. package/.tfcore/tasks/kb-mode-interaction.md +0 -75
  193. package/.tfcore/tasks/shard-doc.md +0 -185
  194. package/.tfcore/templates/architecture-tmpl.yaml +0 -650
  195. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
  196. package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
  197. package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
  198. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
  199. package/.tfcore/templates/market-research-tmpl.yaml +0 -252
  200. package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
  201. package/.tfcore/utils/techieflow-doc-template.md +0 -325
  202. package/.tfcore/utils/tf-codex-bind.py +0 -196
  203. package/.tfcore/utils/tf-codex-telemetry.py +0 -60
  204. package/WORKFLOW.html +0 -1986
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bash
2
+ # tf-verify-screens.sh — drive every screen and check render and visual truth (Sitting 4c, 2026-09-06).
3
+ #
4
+ # bash .tfcore/utils/tf-verify-screens.sh --list tests/.artifacts/verify/list.json --base http://localhost:5099 \
5
+ # [--login-path /login --user ravi --password '…'] [--cookie 'k=v'] [--storage-state file]
6
+ # bash .tfcore/utils/tf-verify-screens.sh --screen home=/ --screen posts=/posts --base URL # a smoke: changed screens only
7
+ # bash .tfcore/utils/tf-verify-screens.sh --list … --cdp http://172.18.144.1:9223 # an embedded-browser desktop head
8
+ # … --error-selector '.my-crash-banner' # the banner THIS stack shows when its client side falls over
9
+ # # (repeatable; defaults cover one framework's id and [data-error-ui])
10
+ #
11
+ # For every screen, at 1280 and 390 px: every control the mockup anchors is present and shows
12
+ # something, no header-only table, no blank page, no Blazor or console error (render); nothing
13
+ # overlaps, nothing has zero size or sits off-screen, no sideways scroll, a stylesheet is loaded
14
+ # (visual). Overlap and off-screen are measured on the rectangle an element paints in, clipped by
15
+ # every scrolling ancestor, and the page is read only after its first render (--render-wait,
16
+ # default 5000 ms). Saves a screenshot per screen and width under tests/.artifacts/verify/screens/ and
17
+ # writes tests/.artifacts/verify/screens.json; that JSON is the evidence a smoke names before a
18
+ # row is written Implemented, and what the verdict script reads. It decides no row's status.
19
+ # Exit 0 every screen OK · 5 a screen failed · 2 the app was unreachable · 4 Playwright missing.
20
+ set -uo pipefail
21
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
+ # `--help` is a request and exits 0; no argument at all is a mistake and exits 3. (The one-line
23
+ # form this replaces computed "0--help" as its exit code and printed a bash error under the help.)
24
+ case "${1:-}" in
25
+ -h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
26
+ "") sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 3 ;;
27
+ esac
28
+ command -v node >/dev/null 2>&1 || { echo "tf-verify-screens: node is required (bash .tfcore/utils/tf-verify-env.sh)" >&2; exit 4; }
29
+ node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1))" 2>/dev/null || {
30
+ echo "tf-verify-screens: the 'playwright' package is not resolvable from this folder; run bash .tfcore/utils/tf-verify-env.sh" >&2; exit 4; }
31
+ exec node "$HERE/tf-verify-screens.mjs" "$@"
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env bash
2
+ # tf-verify-tests.sh — run the acceptance tests and map them to rows (Sitting 4c, 2026-09-06).
3
+ #
4
+ # bash .tfcore/utils/tf-verify-tests.sh [--base URL] [--target <sln|csproj>] [--no-browser] [--no-unit]
5
+ # [--json-out tests/.artifacts/verify/tests.json]
6
+ #
7
+ # Browser tests: `npx playwright test` over tests/verify/ (the JSON reporter). Unit tests:
8
+ # `tf-build.sh test` with normal console verbosity. A test belongs to a row when its name contains
9
+ # the row's id (REQ-UI-004 …). A row is PASS when a test carrying its id passed and none failed,
10
+ # FAIL when one failed (the reason and the screenshot, when the reporter kept one, are recorded),
11
+ # NOT-TESTED when every test carrying its id was SKIPPED, and absent when no test carries its id.
12
+ # A skipped clause is a third outcome, never a failure: `test.skip(!SEEDED, …)` says the state does
13
+ # not exist in the data, which is not a defect (TF-022). Writes tests/.artifacts/verify/tests.json.
14
+ # Exit 0 ran (whatever the results) · 2 nothing could run.
15
+ set -uo pipefail
16
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ BASE=""; TARGET=""; BROWSER=1; UNIT=1; OUT="tests/.artifacts/verify/tests.json"
18
+ while [[ $# -gt 0 ]]; do
19
+ case "$1" in
20
+ --base) BASE="${2:-}"; shift 2 ;;
21
+ --target) TARGET="${2:-}"; shift 2 ;;
22
+ --no-browser) BROWSER=0; shift ;;
23
+ --no-unit) UNIT=0; shift ;;
24
+ --json-out) OUT="${2:-}"; shift 2 ;;
25
+ -h|--help) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
26
+ *) echo "tf-verify-tests: unknown argument $1" >&2; exit 2 ;;
27
+ esac
28
+ done
29
+ mkdir -p "$(dirname "$OUT")" tests/.artifacts/verify
30
+ PWJSON="tests/.artifacts/verify/playwright.json"; PWLOG="tests/.artifacts/verify/playwright.log"; UNITLOG=""; UNITLINE=""
31
+ ran_any=0
32
+
33
+ if [[ $BROWSER -eq 1 ]]; then
34
+ if ls tests/verify/*.spec.* >/dev/null 2>&1 || ls tests/verify/**/*.spec.* >/dev/null 2>&1; then
35
+ if node -e "require.resolve('@playwright/test')" >/dev/null 2>&1; then
36
+ BASE_URL="$BASE" PLAYWRIGHT_JSON_OUTPUT_NAME="$PWJSON" npx playwright test --reporter=json > "$PWLOG" 2>&1
37
+ echo "browser tests: ran (log $PWLOG)"; ran_any=1
38
+ else
39
+ echo "browser tests: @playwright/test is not installed here (bash .tfcore/utils/tf-verify-env.sh); skipped"; rm -f "$PWJSON"
40
+ fi
41
+ else
42
+ echo "browser tests: no spec under tests/verify/; skipped"; rm -f "$PWJSON"
43
+ fi
44
+ else rm -f "$PWJSON"; fi
45
+
46
+ if [[ $UNIT -eq 1 ]]; then
47
+ if [[ -n "$TARGET" ]] || ls *.sln *.slnx >/dev/null 2>&1 || ls tests/*/*.csproj >/dev/null 2>&1; then
48
+ UNITLINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} -- --logger "console;verbosity=normal" 2>&1 | head -1)"
49
+ UNITLOG="$(sed -n 's/.*log \(tests\/\.artifacts\/build\/[^ ;]*\).*/\1/p' <<<"$UNITLINE" | head -1)"
50
+ echo "unit tests: $UNITLINE"; [[ "$UNITLINE" != NOT-RUN* ]] && ran_any=1
51
+ else
52
+ echo "unit tests: no solution or test project found; skipped"
53
+ fi
54
+ fi
55
+
56
+ TF_PWJSON="$PWJSON" TF_UNITLOG="$UNITLOG" TF_UNITLINE="$UNITLINE" TF_OUT="$OUT" python3 - <<'PY'
57
+ import json, os, re
58
+ ID = re.compile(r"(REQ-(?:UI|FN|NFR|RAG)-\d{3})", re.I)
59
+ reqs = {}
60
+ browser = {"ran": False, "passed": 0, "failed": 0, "skipped": 0, "tests": 0}
61
+ unit = {"ran": False, "line": os.environ.get("TF_UNITLINE", "")}
62
+
63
+ def add(rid, outcome, name, source, reason="", shot=""):
64
+ """Record one test against a row. `outcome` is "pass", "fail" or "skip".
65
+
66
+ A SKIPPED clause is the third outcome and neither of the other two. A conditional
67
+ `test.skip(!SEEDED, "needs the seeded dataset")` says the state does not exist in the
68
+ data yet: that is not a pass, and it is not a defect either. Counting it as a failure
69
+ put FAIL on rows no code could clear and sent build-phase back into FIX mode against
70
+ them (TF-022, TfLens 2026-09-09). A row whose clauses were ALL skipped ends
71
+ NOT-TESTED, which the verdict script reads as not measured — never Verified, never a
72
+ defect. tests/regression/run.sh tf_022."""
73
+ r = reqs.setdefault(rid.upper(), {"result": "NOT-TESTED", "source": source, "tests": [],
74
+ "skipped": [], "passed": 0, "failed": 0,
75
+ "reason": "", "screenshot": ""})
76
+ if outcome == "skip":
77
+ r["skipped"].append(name)
78
+ if not r["reason"]:
79
+ r["reason"] = (reason or "the test was skipped")[:200]
80
+ return
81
+ r["tests"].append(name)
82
+ if outcome == "pass":
83
+ r["passed"] += 1
84
+ else:
85
+ if r["result"] != "FAIL": # the first real failure owns the reason
86
+ r["reason"] = reason[:200]
87
+ r["failed"] += 1
88
+ if shot and not r["screenshot"]:
89
+ r["screenshot"] = shot
90
+ r["result"] = "FAIL" if r["failed"] else "PASS"
91
+
92
+ pw = os.environ.get("TF_PWJSON", "")
93
+ if pw and os.path.isfile(pw):
94
+ try:
95
+ data = json.load(open(pw, encoding="utf-8"))
96
+ browser["ran"] = True
97
+ def walk(suite, path):
98
+ for sp in suite.get("specs", []):
99
+ title = " ".join(path + [sp.get("title", "")])
100
+ for t in sp.get("tests", []):
101
+ res = t.get("results", [])
102
+ last = res[-1] if res else {}
103
+ status = last.get("status", t.get("status", "unexpected"))
104
+ # skipped is read FIRST: it is neither a pass nor a failure (TF-022)
105
+ if status == "skipped" or t.get("status") == "skipped":
106
+ outcome = "skip"
107
+ elif status in ("passed", "expected") or t.get("status") == "expected":
108
+ outcome = "pass"
109
+ else:
110
+ outcome = "fail"
111
+ browser["tests"] += 1
112
+ browser[{"pass": "passed", "fail": "failed", "skip": "skipped"}[outcome]] += 1
113
+ err = ""
114
+ if outcome == "skip":
115
+ why = [a.get("description") for a in (t.get("annotations") or [])
116
+ if a.get("type") == "skip" and a.get("description")]
117
+ err = "skipped" + (": " + str(why[0]) if why else "")
118
+ elif last.get("error"):
119
+ err = re.sub(r"\x1b\[[0-9;]*m", "", str(last["error"].get("message", "")).split("\n")[0])
120
+ shot = ""
121
+ for a in last.get("attachments", []):
122
+ if a.get("name") == "screenshot" and a.get("path"):
123
+ shot = a["path"].replace("\\", "/")
124
+ shot = shot[shot.find("tests/"):] if "tests/" in shot else shot
125
+ break
126
+ for rid in set(ID.findall(title)):
127
+ add(rid, outcome, title.strip(), "browser", err, shot)
128
+ for s in suite.get("suites", []):
129
+ walk(s, path + [s.get("title", "")] if s.get("title") and not s["title"].endswith(".ts") and not s["title"].endswith(".js") and not s["title"].endswith(".mjs") else path)
130
+ for s in data.get("suites", []):
131
+ walk(s, [])
132
+ except Exception as e:
133
+ browser["error"] = str(e)
134
+
135
+ ul = os.environ.get("TF_UNITLOG", "")
136
+ if ul and os.path.isfile(ul):
137
+ unit["ran"] = True
138
+ for line in open(ul, encoding="utf-8", errors="replace"):
139
+ m = re.match(r"\s*(Passed|Failed|Skipped)\s+(\S.*?)\s*(\[[^\]]*\])?\s*$", line)
140
+ if not m:
141
+ continue
142
+ # a skipped unit test is recorded as skipped, not dropped: the row then reads
143
+ # NOT-TESTED with the evidence, rather than looking as if no test exists (TF-022)
144
+ outcome = {"Passed": "pass", "Failed": "fail", "Skipped": "skip"}[m.group(1)]
145
+ name = m.group(2).strip()
146
+ for rid in set(ID.findall(name)):
147
+ add(rid, outcome, name, "unit",
148
+ ("unit test failed: " if outcome == "fail" else "unit test skipped: ") + name[:120])
149
+ unit["passed"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "PASS")
150
+ unit["failed"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "FAIL")
151
+ unit["not_tested"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "NOT-TESTED")
152
+
153
+ json.dump({"reqs": reqs, "browser": browser, "unit": unit}, open(os.environ["TF_OUT"], "w"), indent=1)
154
+ p = sum(1 for r in reqs.values() if r["result"] == "PASS")
155
+ f = sum(1 for r in reqs.values() if r["result"] == "FAIL")
156
+ ns = sum(1 for r in reqs.values() if r["result"] == "NOT-TESTED")
157
+ print(f"rows with a test: {len(reqs)} — {p} PASS, {f} FAIL, {ns} NOT-TESTED (every clause skipped)"
158
+ + (f" (browser {browser['passed']}/{browser['tests']} tests passed, {browser['skipped']} skipped)" if browser["ran"] else ""))
159
+ for rid, r in sorted(reqs.items()):
160
+ if r["result"] == "FAIL":
161
+ print(f" FAIL {rid} — {r['reason']}" + (f" — {r['screenshot']}" if r["screenshot"] else ""))
162
+ elif r["result"] == "NOT-TESTED":
163
+ print(f" NOT-TESTED {rid} — {len(r['skipped'])} test(s) skipped, none ran — {r['reason']}")
164
+ print(f"JSON: {os.environ['TF_OUT']}")
165
+ PY
166
+ [[ $ran_any -eq 1 ]] && exit 0 || exit 2
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env python3
2
+ """tf-verify-verdict.py — turn a verify run's evidence into one verdict per row (see tf-verify-verdict.sh).
3
+
4
+ bash .tfcore/utils/tf-verify-verdict.sh <App> [--apply] [--dir tests/.artifacts/verify] [--started <ISO>]
5
+
6
+ Reads, from the evidence folder: list.json (the rows), boot.json (what was booted), tests.json (the
7
+ acceptance tests), screens.json (render and visual), assets.json (tf-assets.sh --json-out),
8
+ parity.json (tf-mockup-parity.sh --json-out) and perf/<REQ>.json (tf-perf.sh --json-out per budget
9
+ row). A file that is absent means that check did not run.
10
+
11
+ For each row, the seven checks in order, and the first that fails is the verdict:
12
+ build the app booted (or was built) — boot.json
13
+ acceptance a test carrying the row's id passed — tests.json
14
+ render every anchored control on its screen shows something — screens.json
15
+ assets every stylesheet and script its screen declares arrived — assets.json
16
+ visual nothing overlaps, nothing clipped or off-screen — screens.json
17
+ mockup the screen carries the structure its mockup draws — parity.json
18
+ speed within the row's perf-budget, only when it declares one — perf/<REQ>.json
19
+ (standards has no script yet and is never listed as run)
20
+
21
+ Verdicts: PASS · FAIL (acceptance) · BUILD-FAIL · RENDER-FAIL · ASSET-FAIL · VISUAL-FAIL · MOCKUP-FAIL ·
22
+ PERF-FAIL · NOT-TESTED (no test carried the id, or every test carrying it was skipped; the screen
23
+ checks passed or did not run) ·
24
+ NOT-OBSERVABLE (an NFR row with no budget and no unit test) · NOT-DRIVEN (its screen was never driven:
25
+ the head could not be booted, or has no driver). A row is Verified only on PASS. NOT-* rows keep their
26
+ status and get a Remark saying so; nothing is ever written as a static-only pass.
27
+
28
+ Writes docs/.last-verify.json (the ledger the verify hook reads: date, app, scope, booted, the checks
29
+ that ran, and every row's verdict) and <dir>/verdicts.json (for tf-verify-emit.sh). With --apply it
30
+ rewrites the Status, % and Remarks cells of every graded row in the checklist. Prints the table.
31
+ Exit 0 · 2 could not run.
32
+ """
33
+ import datetime
34
+ import importlib.util
35
+ import json
36
+ import os
37
+ import re
38
+ import sys
39
+
40
+ HERE = os.path.dirname(os.path.abspath(__file__))
41
+ FAILING = {"BUILD-FAIL": "build", "FAIL": "acceptance", "RENDER-FAIL": "render", "ASSET-FAIL": "assets",
42
+ "VISUAL-FAIL": "visual", "MOCKUP-FAIL": "mockup-parity", "PERF-FAIL": "perf"}
43
+ STATUS = {"PASS": "Verified", "FAIL": "FAIL", "BUILD-FAIL": "FAIL", "RENDER-FAIL": "Needs re-verify", "ASSET-FAIL": "Needs re-verify",
44
+ "VISUAL-FAIL": "Needs re-verify", "MOCKUP-FAIL": "Needs re-verify", "PERF-FAIL": "Needs re-verify"}
45
+
46
+
47
+ def die(msg):
48
+ print(f"tf-verify-verdict: {msg}", file=sys.stderr)
49
+ sys.exit(2)
50
+
51
+
52
+ def load(path):
53
+ try:
54
+ with open(path, encoding="utf-8") as f:
55
+ return json.load(f)
56
+ except FileNotFoundError:
57
+ return None
58
+ except Exception as e:
59
+ die(f"{path} is not valid JSON: {e}")
60
+
61
+
62
+ def perf_grader():
63
+ spec = importlib.util.spec_from_file_location("tf_perf_grade", os.path.join(HERE, "tf-perf-grade.py"))
64
+ mod = importlib.util.module_from_spec(spec)
65
+ spec.loader.exec_module(mod)
66
+ return mod.grade
67
+
68
+
69
+ def norm_route(r):
70
+ r = (r or "").strip().lower().rstrip("/")
71
+ return r or "/"
72
+
73
+
74
+ def words(s, n=60):
75
+ w = s.split()
76
+ return s if len(w) <= n else " ".join(w[:n]) + "…" # the ellipsis rides on the last word: the checker counts words, and a bare "…" was a 61st (miss 29)
77
+
78
+
79
+ def main(argv):
80
+ if len(argv) < 2 or argv[1] in ("-h", "--help"):
81
+ print(__doc__)
82
+ return 0
83
+ app = argv[1]
84
+ apply = "--apply" in argv
85
+ d = argv[argv.index("--dir") + 1] if "--dir" in argv else os.path.join("tests", ".artifacts", "verify")
86
+ started = argv[argv.index("--started") + 1] if "--started" in argv else ""
87
+ today = datetime.date.today().isoformat()
88
+
89
+ lst = load(os.path.join(d, "list.json")) or die("list.json missing: run tf-verify-list.sh first")
90
+ boot = load(os.path.join(d, "boot.json")) or {"mode": "none", "reason": "tf-verify-boot.sh was not run", "reason_kind": "host", "head": "none"}
91
+ tests = (load(os.path.join(d, "tests.json")) or {}).get("reqs", {})
92
+ tests_ran = os.path.isfile(os.path.join(d, "tests.json"))
93
+ screens = load(os.path.join(d, "screens.json")) or {}
94
+ scr_by_name = {s["name"]: s for s in screens.get("screens", [])}
95
+ assets = load(os.path.join(d, "assets.json")) or {}
96
+ pages = {norm_route(p.get("path")): p for p in assets.get("pages", [])}
97
+ parity = load(os.path.join(d, "parity.json")) or {}
98
+ par_by_name = {s.get("screen"): s for s in parity.get("screens", [])}
99
+ par_by_route = {norm_route(s.get("route")): s for s in parity.get("screens", [])}
100
+ grade = perf_grader()
101
+
102
+ booted = boot.get("mode") not in (None, "none")
103
+ build_failed = boot.get("reason_kind") == "build-error"
104
+ boot_text = (f"{boot.get('head')} via {boot.get('rung')} at {boot.get('url')}" if booted
105
+ else f"not booted: {boot.get('reason')}")
106
+ checks_ran = set()
107
+ if booted or build_failed:
108
+ checks_ran.add("build")
109
+
110
+ rows_out, ledger_rows, table = [], {}, []
111
+ for r in lst["rows"]:
112
+ rid = r["id"]
113
+ prior = r.get("status_raw") or ""
114
+ notes = []
115
+ scr = scr_by_name.get(r.get("screen") or "")
116
+ driven = bool(scr) and scr.get("render") != "UNREACHABLE"
117
+ t = tests.get(rid)
118
+ # A row whose every clause was SKIPPED is NOT-TESTED: the acceptance gate did not run on
119
+ # it. It is neither a pass nor a defect, so it grades exactly like a row with no test at
120
+ # all — never Verified, no gate record (TF-022). `t` is kept for the note.
121
+ skipped_only = bool(t) and t.get("result") == "NOT-TESTED"
122
+ if skipped_only:
123
+ notes.append(f"acceptance not measured ({len(t.get('skipped') or [])} test(s) skipped, none ran)")
124
+ t = None
125
+ # every check on its own: (name, ran, failed, verdict, class, detail, evidence); the first failure wins
126
+ checks = []
127
+
128
+ # 1 build
129
+ checks.append(("build", booted or build_failed, build_failed, "BUILD-FAIL", "build-error", boot.get("reason", "build failed"), ""))
130
+ # 2 acceptance
131
+ if t:
132
+ checks.append(("acceptance", True, t["result"] != "PASS", "FAIL", "assert-fail", t.get("reason") or "test failed", t.get("screenshot") or ""))
133
+ # 3 render
134
+ if scr:
135
+ if scr.get("render") == "UNREACHABLE":
136
+ f = next((x for w in scr["widths"] for x in w.get("findings", [])), {})
137
+ checks.append(("render", True, True, "RENDER-FAIL", "other", f.get("detail", "screen unreachable"), ""))
138
+ else:
139
+ bad = [(w, f) for w in scr["widths"] for f in w.get("findings", []) if f["check"] == "render"]
140
+ w, f = bad[0] if bad else (None, None)
141
+ checks.append(("render", True, bool(bad), "RENDER-FAIL", f["class"] if f else None,
142
+ f"{f['detail']} on {r['screen']} @{w['width']}" if f else "", w.get("screenshot", "") if w else ""))
143
+ # 4 assets
144
+ pg = pages.get(norm_route(r.get("route"))) if r.get("route") else None
145
+ if pg and pg.get("graded", 0) > 0:
146
+ bad = next((a for a in pg.get("assets", []) if a.get("graded") and (a.get("status") in (None, 0) or a.get("status") >= 400 or a.get("bytes", 1) == 0)), None)
147
+ checks.append(("assets", True, pg.get("failed", 0) > 0, "ASSET-FAIL", "missing-asset",
148
+ f"{pg.get('failed', 0)} declared asset(s) did not arrive on {r['route']}" + (f": {bad.get('url')} → {bad.get('status')}" if bad else ""), ""))
149
+ elif pg and pg.get("redirected"):
150
+ notes.append("assets not graded (auth wall)")
151
+ # 5 visual
152
+ if scr and driven:
153
+ bad = [(w, f) for w in scr["widths"] for f in w.get("findings", []) if f["check"] == "visual"]
154
+ w, f = bad[0] if bad else (None, None)
155
+ checks.append(("visual", True, bool(bad), "VISUAL-FAIL", f["class"] if f else None,
156
+ f"{f['detail']} on {r['screen']}" if f else "", w.get("screenshot", "") if w else ""))
157
+ # 6 mockup parity
158
+ ps = (par_by_name.get(r.get("screen")) or par_by_route.get(norm_route(r.get("route")))) if r.get("screen") else None
159
+ if ps:
160
+ v = ps.get("verdict")
161
+ if v in ("PASS", "FAIL"):
162
+ f = (ps.get("findings") or [{}])[0]
163
+ checks.append(("mockup-parity", True, v == "FAIL", "MOCKUP-FAIL", "mockup-drift",
164
+ f"{f.get('class', 'drift')} on {f.get('key', ps.get('screen'))} @{f.get('width', '')}: {str(f.get('detail', ''))[:80]}",
165
+ os.path.join(d, "parity.json")))
166
+ elif v == "UNGRADEABLE":
167
+ notes.append("mockup-parity UNGRADEABLE (add data-testid anchors to the mockup)")
168
+ elif v == "NO-MOCKUP":
169
+ notes.append("no mockup to compare")
170
+ # 7 speed
171
+ if r.get("perf_budget"):
172
+ pf = load(os.path.join(d, "perf", f"{rid}.json"))
173
+ if pf is None:
174
+ notes.append("perf not measured (no tf-perf run for this row)")
175
+ else:
176
+ g = grade(r["perf_budget"], pf)
177
+ if g["verdict"] in ("PERF-OK", "PERF-MARGINAL", "PERF-FAIL"):
178
+ checks.append(("perf", True, g["verdict"] == "PERF-FAIL", "PERF-FAIL", g.get("failure_class") or "slow-ttfb", g["reason"], ""))
179
+ if g["verdict"] == "PERF-MARGINAL":
180
+ notes.append(f"⚠ perf: {g['reason']}")
181
+ elif g["verdict"] == "PERF-UNMEASURED":
182
+ notes.append(f"perf not measured: {g['reason']}")
183
+
184
+ ran = [c[0] for c in checks if c[1]]
185
+ first = next((c for c in checks if c[1] and c[2]), None)
186
+ verdict, cls, detail, evidence = (first[3], first[4], first[5], first[6]) if first else (None, None, "", "")
187
+
188
+ # the honest non-verdicts
189
+ if verdict is None:
190
+ if r["class"] == "NFR" and not r.get("perf_budget") and not t:
191
+ verdict = "NOT-OBSERVABLE"
192
+ detail = "NFR row with no perf-budget and no unit test carrying its id"
193
+ elif not t and r.get("screen") and not driven:
194
+ verdict = "NOT-DRIVEN"
195
+ detail = (f"{boot.get('head', 'the app')} not driven: {boot.get('reason')}" if not booted
196
+ else f"screen {r['screen']} was not driven in this run")
197
+ elif not t:
198
+ verdict = "NOT-TESTED"
199
+ detail = (f"every test named {rid} was skipped" if skipped_only else f"no test named {rid} ran") \
200
+ + (f"; screen {r['screen']} renders and looks right" if driven else "")
201
+ elif r.get("screen") and not driven and not booted:
202
+ verdict = "NOT-DRIVEN"
203
+ detail = f"test passed but {boot.get('head', 'the app')} was not driven: {boot.get('reason')}"
204
+ else:
205
+ verdict = "PASS"
206
+ parts = [f"test {t['tests'][0][:60]}" if t and t.get("tests") else "test passed"]
207
+ if driven:
208
+ parts.append(f"{r['screen']} renders and looks right @{'/'.join(str(w['width']) for w in scr['widths'])}")
209
+ if "mockup-parity" in ran:
210
+ parts.append("matches its mockup")
211
+ if "perf" in ran:
212
+ parts.append("within its speed budget")
213
+ detail = "; ".join(parts)
214
+
215
+ status = STATUS.get(verdict, prior)
216
+ pct = r.get("pct")
217
+ if verdict == "PASS":
218
+ pct = 100
219
+ elif verdict in ("RENDER-FAIL", "ASSET-FAIL", "VISUAL-FAIL", "MOCKUP-FAIL", "PERF-FAIL"):
220
+ pct = min(pct if pct is not None else 75, 75)
221
+ gate = FAILING.get(verdict)
222
+ marks = {"FAIL": "⚠ acceptance", "BUILD-FAIL": "⚠ build", "RENDER-FAIL": "⚠ render", "ASSET-FAIL": "⚠ assets",
223
+ "VISUAL-FAIL": "⚠ visual", "MOCKUP-FAIL": "⚠ mockup-parity", "PERF-FAIL": "⚠ perf",
224
+ "NOT-TESTED": "not verified", "NOT-OBSERVABLE": "not observable", "NOT-DRIVEN": "not verified", "PASS": "PASS"}
225
+ remark = f"{today} verify: {marks[verdict]} — {detail}"
226
+ if evidence:
227
+ remark += f" ({evidence})"
228
+ if notes:
229
+ remark += "; " + "; ".join(notes[:2])
230
+ remark = words(remark.replace("|", "/"), 60)
231
+ emit = verdict == "PASS" or gate is not None
232
+ row = {"id": rid, "class": r["class"], "verdict": verdict, "gate": gate, "gates_run": ran, "failure_class": cls if gate else None,
233
+ "prior_verdict": prior or None, "status": status, "pct": pct, "remark": remark, "screen": r.get("screen"),
234
+ "emit_gate": emit, "title": r.get("title", "")}
235
+ rows_out.append(row)
236
+ ledger_rows[rid] = verdict
237
+ table.append((rid, verdict, gate or "-", status, detail[:70]))
238
+
239
+ checks = []
240
+ for c in ("build", "acceptance", "render", "assets", "visual", "mockup-parity", "perf"):
241
+ if any(c in x["gates_run"] for x in rows_out):
242
+ checks.append(c)
243
+ ledger = {"date": today, "app": app, "scope": lst.get("scope", "all"), "booted": boot_text, "gates": checks,
244
+ "evidence": d, "run_id": started, "rows": ledger_rows}
245
+ os.makedirs("docs", exist_ok=True)
246
+ with open(os.path.join("docs", ".last-verify.json"), "w", encoding="utf-8") as f:
247
+ json.dump(ledger, f, indent=1)
248
+ with open(os.path.join(d, "verdicts.json"), "w", encoding="utf-8") as f:
249
+ json.dump({"app": app, "scope": lst.get("scope", "all"), "date": today, "started": started, "booted": boot_text,
250
+ "build_result": "fail" if build_failed else ("pass" if booted else "not-run"), "checks": checks,
251
+ "checklist": lst.get("checklist"), "rows": rows_out}, f, indent=1)
252
+
253
+ applied = 0
254
+ if apply:
255
+ cl = lst.get("checklist")
256
+ text = open(cl, encoding="utf-8").read()
257
+ by_id = {x["id"]: x for x in rows_out}
258
+ out = []
259
+ for line in text.splitlines(keepends=True):
260
+ m = re.match(r"^(\s*\|\s*`?)(REQ-[A-Z]+-\d+)(`?\s*\|)", line)
261
+ if m and m.group(2) in by_id:
262
+ c = line.rstrip("\r\n").strip().strip("|").split("|")
263
+ if len(c) >= 6:
264
+ x = by_id[m.group(2)]
265
+ c[2] = f" {x['status']} "
266
+ c[3] = f" {x['pct'] if x['pct'] is not None else ''}{'%' if x['pct'] is not None and '%' in c[3] else ''} "
267
+ c[4] = f" {x['remark']} "
268
+ nl = "\r\n" if line.endswith("\r\n") else "\n"
269
+ line = "|" + "|".join(c) + "|" + nl
270
+ applied += 1
271
+ out.append(line)
272
+ with open(cl, "w", encoding="utf-8") as f:
273
+ f.write("".join(out))
274
+
275
+ counts = {}
276
+ for x in rows_out:
277
+ counts[x["verdict"]] = counts.get(x["verdict"], 0) + 1
278
+ print(f"# tf-verify-verdict — {app} — scope {lst.get('scope')} — booted: {boot_text}")
279
+ print(f"Checks that ran: {', '.join(checks) or 'none'}. Rows: " + ", ".join(f"{v} {k}" for k, v in sorted(counts.items())))
280
+ print()
281
+ print("| ID | Verdict | First failing check | Status | Detail |")
282
+ print("|---|---|---|---|---|")
283
+ for t in table:
284
+ print("| " + " | ".join(str(x).replace("|", "/") for x in t) + " |")
285
+ print()
286
+ print(f"Ledger: docs/.last-verify.json ({len(ledger_rows)} rows). Verdicts: {os.path.join(d, 'verdicts.json')}."
287
+ + (f" Checklist: {applied} row(s) rewritten in {lst.get('checklist')}." if apply else " Checklist untouched (no --apply)."))
288
+ return 0
289
+
290
+
291
+ if __name__ == "__main__":
292
+ sys.exit(main(sys.argv))
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ # tf-verify-verdict.sh — one verdict per row from a verify run's evidence (Sitting 4c, 2026-09-06).
3
+ #
4
+ # bash .tfcore/utils/tf-verify-verdict.sh <App> [--apply] [--dir tests/.artifacts/verify] [--started <ISO>]
5
+ #
6
+ # Reads list.json, boot.json, tests.json, screens.json, assets.json, parity.json and perf/<REQ>.json
7
+ # from the evidence folder, applies the seven checks in order, records the first that fails, writes
8
+ # docs/.last-verify.json (the ledger the verify hook reads, with every row's verdict) and verdicts.json,
9
+ # and with --apply rewrites the Status, % and Remarks cells of the graded rows in the checklist.
10
+ # A row is Verified only when everything that ran passed; a row whose screen was never driven keeps
11
+ # its status and says so. Details: tf-verify-verdict.py.
12
+ set -u
13
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ command -v python3 >/dev/null 2>&1 || { echo "tf-verify-verdict: python3 is required" >&2; exit 2; }
15
+ exec python3 "$HERE/tf-verify-verdict.py" "$@"
@@ -99,6 +99,65 @@ case "$cmd" in
99
99
  OUTCOME="${1:-complete}"; SUMMARY="${2:-}"
100
100
  case "$OUTCOME" in complete|blocked) ;; *) SUMMARY="$OUTCOME $SUMMARY"; OUTCOME="complete" ;; esac
101
101
  mkdir -p "$STATE_DIR" 2>/dev/null || true
102
+ # The sentinel comes AFTER the status gate: on 2026-09-06 an OpenCode build wrote
103
+ # `blocked` with the status file untouched since day-1, every row Not Started and no run
104
+ # record (MISS-TechieFlow-20260906-13). So `done` is refused, without writing the
105
+ # sentinel, while the status file is older than the goal run's start or no run record
106
+ # has been appended since the goal started. Exit stays 0 (no veto); the message says
107
+ # what to finish first. TF_YOLO_DONE_FORCE=1 skips the check (owner use).
108
+ if [[ "${TF_YOLO_DONE_FORCE:-0}" != "1" && -f "$STATE_DIR/goal.json" ]]; then
109
+ _why="$(python3 - "$STATE_DIR/goal.json" "$ROOT" <<'PY2' 2>/dev/null
110
+ import json, os, sys, datetime
111
+ gj, root = sys.argv[1], sys.argv[2]
112
+ try:
113
+ started = json.load(open(gj)).get("started") or ""
114
+ t0 = datetime.datetime.strptime(started[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=datetime.timezone.utc).timestamp()
115
+ except Exception:
116
+ sys.exit(0)
117
+ why = []
118
+ ps = os.path.join(root, "PROJECT-STATUS" + ".md")
119
+ if os.path.isfile(ps) and os.path.getmtime(ps) < t0 - 60:
120
+ why.append("the status file has not been written since this goal run started (%s)" % started)
121
+ # The run record must be for the command the phase marker names (tf-phase.sh start <cmd>):
122
+ # a verify-phase record alone let a build close without its build-phase record
123
+ # (MISS-TechieFlow-20260906-15).
124
+ want_cmd = None
125
+ try:
126
+ pj = os.path.join(root, ".tfcore", ".session", "phase.json")
127
+ if os.path.isfile(pj) and os.path.getmtime(pj) >= t0 - 60:
128
+ want_cmd = (json.load(open(pj)) or {}).get("cmd") or None
129
+ except Exception:
130
+ want_cmd = None
131
+ runs = os.path.join(root, "docs", "metrics", "runs.jsonl"); last = None; last_cmd = None
132
+ try:
133
+ for line in open(runs, encoding="utf-8", errors="replace"):
134
+ try: r = json.loads(line)
135
+ except Exception: continue
136
+ ts = r.get("ts")
137
+ if not ts: continue
138
+ try:
139
+ te = datetime.datetime.strptime(ts[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=datetime.timezone.utc).timestamp()
140
+ except Exception:
141
+ continue
142
+ if te >= t0 - 60:
143
+ last = ts
144
+ if want_cmd and r.get("kind") == "run" and r.get("cmd") == want_cmd:
145
+ last_cmd = ts
146
+ except Exception:
147
+ pass
148
+ if os.path.isfile(runs):
149
+ if last is None:
150
+ why.append("no run record has been appended to docs/metrics/runs.jsonl since the goal started")
151
+ elif want_cmd and last_cmd is None:
152
+ why.append("no run record for %s (the command the phase marker names) has been appended since the goal started" % want_cmd)
153
+ print("; ".join(why))
154
+ PY2
155
+ )"
156
+ if [[ -n "$_why" ]]; then
157
+ echo "GOAL-DONE refused: the status gate has not run — $_why. Finish the gate (_status-update-gate.md: the status file, its HTML, the checker, the run record), then run this command again. Nothing was written."
158
+ exit 0
159
+ fi
160
+ fi
102
161
  printf '{"outcome":%s,"summary":%s,"ts":"%s"}\n' \
103
162
  "$(json_escape "$OUTCOME")" "$(json_escape "$SUMMARY")" "$(now_utc)" > "$DONE" 2>/dev/null || true
104
163
  # The grant ENDS WITH THE GOAL. Until 2026-08-28 `done` left the flag in place,