@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
@@ -26,7 +26,7 @@ set -uo pipefail
26
26
 
27
27
  command -v python3 >/dev/null 2>&1 || { echo "tf-metrics: python3 is required." >&2; exit 1; }
28
28
 
29
- exec python3 - "$@" <<'PYEOF'
29
+ TF_METRICS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" exec python3 - "$@" <<'PYEOF'
30
30
  import glob
31
31
  import json
32
32
  import os
@@ -34,6 +34,7 @@ import re
34
34
  import subprocess
35
35
  import sys
36
36
  from collections import Counter, defaultdict, OrderedDict
37
+ from datetime import datetime
37
38
 
38
39
  STREAMS = ("runs", "gates", "sessions", "commits", "misses")
39
40
  VERDICTS = ("Verified", "Needs re-verify", "FAIL", "Blocked", "Implemented", "Done (pre-existing)")
@@ -52,20 +53,155 @@ LATE_GATES = {"perf": "2026-08-10", "assets": "2026-08-31", "mockup-parity": "20
52
53
  # fill, so counting it as "not assessed" understates the practice being measured.
53
54
  # It leaves that field's denominator and is reported separately. SCHEMA.md §5.5.6 /
54
55
  # §3.5. Keep this table in sync when an optional field is added to any stream.
55
- FIELD_SINCE = {"why_missed": "2026-08-28"}
56
+ FIELD_SINCE = {"why_missed": "2026-08-28", "sort": "2026-09-07", "what": "2026-09-07"}
57
+
58
+
59
+ def _span(r):
60
+ """Seconds between a run's own `started` and `ended`, or None if they cannot be read."""
61
+ a, b = r.get("started"), r.get("ended")
62
+ if not isinstance(a, str) or not isinstance(b, str):
63
+ return None
64
+ try:
65
+ fmt = "%Y-%m-%dT%H:%M:%SZ"
66
+ return int((datetime.strptime(b, fmt) - datetime.strptime(a, fmt)).total_seconds())
67
+ except Exception:
68
+ return None
69
+
70
+
71
+ def _dur(r):
72
+ """A run's duration in seconds, or None when the record does not carry a real one.
73
+
74
+ THE TIMESTAMPS WIN. A record stores a start, an end and a duration, and on a stream
75
+ written before the emitter checked them the third can contradict the first two:
76
+ TechieBlog holds 14 such records, one storing -166 and thirteen storing a plausible
77
+ round number (3600, 2700, 1800, 900, 600) that bears no relation to their own clocks
78
+ -- `refresh-status` started 20:05:00, ended 17:59:39, stored 600. Only the one that
79
+ stored the negative was ever detectable by reading the number alone.
80
+
81
+ So the duration is taken from the timestamps whenever they can be read, and a record
82
+ whose timestamps are themselves impossible carries NO duration rather than a
83
+ plausible one. The count of both is published (`duration_excluded_n`,
84
+ `duration_recomputed_n`) and printed, because "32 records, correct" is only honest
85
+ when the reader is also told that 14 were dropped. Every consumer goes through this
86
+ one function, so the two readers of a stream cannot disagree about the same record.
87
+ tests/regression/run.sh tf_015."""
88
+ span = _span(r)
89
+ if span is not None:
90
+ return span if span > 0 else None
91
+ d = r.get("duration_s")
92
+ return d if isinstance(d, (int, float)) and d > 0 else None
93
+
94
+
95
+ def _dur_quality(records):
96
+ """(impossible, absent, recomputed) -- what _dur changed, split so the report can be
97
+ accurate rather than merely alarming.
98
+
99
+ "Its start is after its end" and "it never carried a duration" are different facts and
100
+ were briefly reported as one, which made this repo look like it held 30 corrupt records
101
+ when it holds 30 records that simply record no elapsed time (`started` == `ended`, no
102
+ `duration_s`). Neither reached a figure before or after -- but a reader told the wrong
103
+ reason chases the wrong thing."""
104
+ impossible = absent = recomputed = 0
105
+ for r in records:
106
+ span, stored = _span(r), r.get("duration_s")
107
+ has_stored = isinstance(stored, (int, float)) and stored > 0
108
+ if span is not None and span < 0:
109
+ impossible += 1
110
+ elif span is not None and span == 0:
111
+ absent += 1
112
+ elif span is None and not has_stored:
113
+ absent += 1
114
+ elif span is not None and has_stored and abs(span - stored) > 1:
115
+ recomputed += 1
116
+ return impossible, absent, recomputed
56
117
 
57
118
  # Fields a `miss-amend` record may complete, and their closed vocabularies.
58
119
  # Kept identical to tf-emit.sh's `_AMENDABLE`; the emitter enforces it on write and
59
120
  # this enforces it again on read, because a merged stream can carry records this
60
121
  # machine's emitter never saw. SCHEMA.md §5.5.7 states the rule for extending it.
122
+ # `what` is the miss's own sentence: free text, not a vocabulary. Kept amendable
123
+ # because the never-overwrite rule -- not the vocabulary -- is what stops an amend
124
+ # altering a fact. MISS-TechieFlow-20260909-02; tests/regression/run.sh tf_018.
125
+ AMENDABLE_TEXT_FIELDS = ("what",)
126
+
61
127
  AMENDABLE_FIELDS = {
62
128
  "why_missed": ("missing-checklist-item", "insufficient-verify-method",
63
129
  "code-audit-limitation", "ambiguous-acceptance",
64
130
  "dependency-not-declared", "instruction-ignored", "other"),
131
+ "sort": ("spec", "unsaid", "weak-check", "ignored"),
65
132
  }
133
+ # The four-question sort in words, for the report (SCHEMA.md §5.5.10).
134
+ SORT_WORDS = {"spec": "the app's spec did not say it", "unsaid": "the framework never said it",
135
+ "weak-check": "the check was too weak", "ignored": "said and ignored"}
66
136
  MIN_N = 3 # fewer supporting records than this -> "insufficient data", never a number
67
137
 
68
138
 
139
+ # --------------------------------------------------- list price (report time)
140
+ # WHERE PRICING BELONGS (owner, 2026-09-10). The framework's job is to record what it MEASURED:
141
+ # tokens, per model, and whatever cost the provider itself reported. Turning tokens into money is
142
+ # a reporting job — this file's, and TfLens's — so it happens HERE, at report time, from the
143
+ # fields already on the stream. Nothing priced is ever written to a record. That is SCHEMA.md §8's
144
+ # existing rule ("derived metrics are computed, never stored") applied to money, and it is why a
145
+ # rate card can change, or a reader disagree with this one, without a single record being wrong.
146
+ #
147
+ # The rate itself comes from `tf-model-pick.sh rate`, so the rate card is defined once and the
148
+ # project's own `.tfcore/rate-card.json` wins where it exists.
149
+ _RATE_CACHE = {}
150
+ REPO_OF = {} # id(record) -> the repo it was read from; never written into a record
151
+
152
+
153
+ def rate_for(repo, model):
154
+ key = (repo, model)
155
+ if key not in _RATE_CACHE:
156
+ got = None
157
+ for base in (os.path.join(repo, ".tfcore", "utils"),
158
+ os.path.join(os.environ.get("TF_METRICS_DIR", ""), "..", "utils")):
159
+ script = os.path.join(base, "tf-model-pick.sh")
160
+ if not os.path.isfile(script):
161
+ continue
162
+ try:
163
+ out = subprocess.run(["bash", script, "rate", model],
164
+ capture_output=True, text=True, timeout=20,
165
+ env=dict(os.environ, TF_PROJECT_DIR=repo))
166
+ if out.returncode == 0:
167
+ parts = (out.stdout or "").strip().split("\t")
168
+ if len(parts) >= 4:
169
+ got = tuple(float(x) for x in parts[:4])
170
+ break
171
+ except Exception:
172
+ pass
173
+ _RATE_CACHE[key] = got
174
+ return _RATE_CACHE[key]
175
+
176
+
177
+ def list_price(repo, rec):
178
+ """What one run's measured tokens cost at list price, or None when nothing prices its model.
179
+
180
+ Output tokens are exact per model (`model_tokens_out`). Input and cache tokens are counted for
181
+ the window as a whole, so where a window ran several models they are shared out by each
182
+ model's output share — arithmetic, and the caller is told."""
183
+ split = rec.get("model_tokens_out")
184
+ if not isinstance(split, dict) or not split:
185
+ m = rec.get("model")
186
+ if not m or rec.get("tokens_out") is None:
187
+ return None
188
+ split = {m: rec.get("tokens_out") or 0}
189
+ total_out = sum(v or 0 for v in split.values())
190
+ ti = rec.get("tokens_in") or 0
191
+ tcr = rec.get("tokens_cache_read") or 0
192
+ tcw = rec.get("tokens_cache_write") or 0
193
+ usd, priced = 0.0, 0
194
+ for m, out in split.items():
195
+ r = rate_for(repo, m)
196
+ if not r:
197
+ continue
198
+ share = (float(out or 0) / total_out) if total_out else 0.0
199
+ usd += (ti * share * r[0] + (out or 0) * r[1]
200
+ + tcr * share * r[2] + tcw * share * r[3]) / 1_000_000.0
201
+ priced += 1
202
+ return round(usd, 6) if priced else None
203
+
204
+
69
205
  # ---------------------------------------------------------------- utilities
70
206
  def die(msg):
71
207
  sys.stderr.write("tf-metrics: %s\n" % msg)
@@ -94,6 +230,42 @@ def read_stream(repo, stream):
94
230
  return out
95
231
 
96
232
 
233
+ def apply_voids(runs, app=None):
234
+ """Drop the run records a `run-void` names, and hand back what was dropped.
235
+
236
+ A stream is append-only, so a run record written with a wrong figure can be
237
+ neither edited nor deleted — and nothing inside the record says it is wrong, so
238
+ every figure over it is quietly polluted. The correction is therefore another
239
+ record: `run-void` names a run by the pair every run carries (`cmd` + `started`)
240
+ and says why (SCHEMA.md §2.7, written by `tf-emit.sh --void-run`).
241
+
242
+ Two rules, both the same one this file applies everywhere else. The voided record
243
+ leaves every figure — it is not clamped, guessed at or half-counted. And the count
244
+ of what left travels with the figures, because a total offered without its
245
+ exclusions is just a different wrong number. A void naming no run on this stream
246
+ is an orphan: it is counted and reported, never silently dropped, exactly as an
247
+ orphaned miss-amend is. tests/regression/run.sh tf_void."""
248
+ key = lambda r: (r.get("cmd"), r.get("started"))
249
+ voids = {}
250
+ for v in runs:
251
+ if v.get("kind") == "run-void" and v.get("started"):
252
+ voids[key(v)] = v
253
+ kept, dropped, reasons = [], 0, []
254
+ matched = set()
255
+ for r in runs:
256
+ if r.get("kind") == "run-void":
257
+ continue
258
+ if key(r) in voids:
259
+ matched.add(key(r))
260
+ dropped += 1
261
+ v = voids[key(r)]
262
+ reasons.append("%s %s %s — %s" % (app or r.get("app") or "?", r.get("cmd"),
263
+ r.get("started"), v.get("reason") or "no reason recorded"))
264
+ continue
265
+ kept.append(r)
266
+ return kept, dropped, reasons, len(set(voids) - matched)
267
+
268
+
97
269
  def emit(repo, stream, records, dry_run, quiet=False):
98
270
  """Append via tf-emit.sh — the single append primitive. Never write JSONL directly.
99
271
 
@@ -206,6 +378,7 @@ def analyse_misses(misses):
206
378
  opened = [m for m in misses if m.get("kind") == "miss" and not m.get("backfilled")]
207
379
  fixes = [m for m in misses if m.get("kind") == "miss-fix" and not m.get("backfilled")]
208
380
  amends = [m for m in misses if m.get("kind") == "miss-amend"]
381
+ reviews = [m for m in misses if m.get("kind") == "review"]
209
382
 
210
383
  # ---- fold amendments into their parents BEFORE anything is counted (§5.5.7).
211
384
  # An amend completes a field the miss left null; it can never overwrite one, so
@@ -221,11 +394,14 @@ def analyse_misses(misses):
221
394
  for a in sorted(amends, key=lambda r: r.get("ts") or ""):
222
395
  parent = opened_by_id.get(a.get("miss_id"))
223
396
  fld = a.get("field")
224
- if parent is None or fld not in AMENDABLE_FIELDS:
397
+ if parent is None or (fld not in AMENDABLE_FIELDS and fld not in AMENDABLE_TEXT_FIELDS):
225
398
  orphan_amends += 1
226
399
  continue
227
- if parent.get(fld) is None and a.get("value") in AMENDABLE_FIELDS[fld]:
228
- parent[fld] = a["value"]
400
+ val = a.get("value")
401
+ ok = (isinstance(val, str) and val.strip()) if fld in AMENDABLE_TEXT_FIELDS \
402
+ else (val in AMENDABLE_FIELDS.get(fld, ()))
403
+ if parent.get(fld) is None and ok:
404
+ parent[fld] = val
229
405
  amended += 1
230
406
 
231
407
  # ---- eligibility floor for optional fields added mid-stream (SCHEMA.md §3.5,
@@ -235,8 +411,10 @@ def analyse_misses(misses):
235
411
  # reported separately — never silently, and never backfilled with a value
236
412
  # nobody assessed at the time.
237
413
  def _eligible(rec, field):
414
+ # A record written after the field existed, or one that carries it anyway (an older
415
+ # record completed by an amend is sorted, whatever its date; Session 5, 2026-09-07).
238
416
  since = FIELD_SINCE.get(field)
239
- return not since or (rec.get("ts") or "") >= since
417
+ return not since or (rec.get("ts") or "") >= since or bool(rec.get(field))
240
418
 
241
419
  # Latest fix per miss decides open/closed; a fix naming no known miss is an orphan.
242
420
  known = {m.get("miss_id") for m in opened}
@@ -349,13 +527,36 @@ def analyse_misses(misses):
349
527
  sole_tokens, sole_priced_n = tok(sole)
350
528
  shared_priced = [f for f in shared if f.get("tokens_out") is not None]
351
529
 
352
- # Dollars exist ONLY where a harness measured them. Claude Code and Codex carry
530
+ # Dollars exist ONLY where a harness measured them. Claude Code carries
353
531
  # cost_usd:null permanently (SCHEMA.md §4) and are never priced from a rate card
354
532
  # here — a pooled sum over mixed harnesses would silently under-report.
355
- paid = [f for f in sole if f.get("cost_usd") is not None]
533
+ #
534
+ # A null was never the only way to have no money figure, though. An OpenCode run on a
535
+ # subscription records cost_usd 0.0 — a real, measured zero, because the plan was already
536
+ # paid for — and averaging that in as a FREE repair is the TF-005 defect again, in the
537
+ # column right beside the one TF-005 was found in. Only a `metered` record is money
538
+ # (SCHEMA.md §2.5b). A record written before billing_mode existed is admitted on its old
539
+ # terms, and counted, so the change does not silently rewrite the past.
540
+ paid = [f for f in sole if f.get("cost_usd") is not None
541
+ and (f.get("billing_mode") in (None, "metered"))]
542
+ unpriced_subscription_n = len([f for f in sole if f.get("cost_usd") is not None
543
+ and f.get("billing_mode") not in (None, "metered")])
544
+ # And the figure that works on every harness: what the repair's tokens cost at list price,
545
+ # worked out here from the tokens the record carries. It is the only money-shaped number a
546
+ # Claude Code repair can have, because a Max subscription bills nothing per token.
547
+ listed = [p for p in (list_price(REPO_OF.get(id(f), ""), f) for f in sole) if p is not None]
356
548
  escaped = [m for m in opened if m.get("found_by") in ("owner", "production")]
357
549
  design = [m for m in opened if m.get("miss_class") == "unspecified-gap"]
358
550
 
551
+ # Owner reviews (§5.5.9): how many corrections each phase's output needed, and what
552
+ # producing it and correcting it cost. Token means divide by the records that carry the
553
+ # figure (§5.5.8), never by all of them.
554
+ def _mean(key):
555
+ xs = [r[key] for r in reviews if isinstance(r.get(key), (int, float))]
556
+ return (round(float(sum(xs)) / len(xs), 1) if len(xs) >= MIN_N else None), len(xs)
557
+ tp, tp_n = _mean("tokens_produce")
558
+ tc, tc_n = _mean("tokens_correct")
559
+
359
560
  return {
360
561
  "misses_total": len(opened),
361
562
  "miss_fixes_total": len(fixes),
@@ -381,6 +582,22 @@ def analyse_misses(misses):
381
582
  if m.get("found_by") in ("owner", "production")
382
583
  and not m.get("why_missed")
383
584
  and _eligible(m, "why_missed")),
585
+ # Whose gap (§5.5.10, FR-32): the four-question sort. Same denominator rule as
586
+ # why_missed — records that carry it, out of records that could have.
587
+ "sort_n": sum(1 for m in opened if m.get("sort")),
588
+ "sort_eligible": sum(1 for m in opened if _eligible(m, "sort")),
589
+ "sort_predates_field": sum(1 for m in opened if not _eligible(m, "sort")),
590
+ "sort": OrderedDict(
591
+ sorted(Counter(m["sort"] for m in opened if m.get("sort")).items(),
592
+ key=lambda kv: (-kv[1], kv[0]))),
593
+ # Owner reviews (§5.5.9)
594
+ "reviews_n": len(reviews),
595
+ "review_corrections": sum(r["corrections"] for r in reviews if isinstance(r.get("corrections"), int)),
596
+ "reviews_by_phase": OrderedDict(
597
+ sorted(Counter(r.get("phase") or "?" for r in reviews).items(),
598
+ key=lambda kv: (-kv[1], kv[0]))),
599
+ "review_tokens_produce_mean": tp, "review_tokens_produce_n": tp_n,
600
+ "review_tokens_correct_mean": tc, "review_tokens_correct_n": tc_n,
384
601
  "class_distribution": OrderedDict(
385
602
  sorted(Counter(m.get("miss_class") or "?" for m in opened).items(),
386
603
  key=lambda kv: (-kv[1], kv[0]))),
@@ -429,6 +646,12 @@ def analyse_misses(misses):
429
646
  "cost_usd_per_miss_measured": round(
430
647
  sum(f["cost_usd"] for f in paid) / len(paid), 4) if len(paid) >= MIN_N else None,
431
648
  "cost_usd_records": len(paid),
649
+ # Repairs whose dollars were measured and are NOT money: a subscription's zero, a local
650
+ # model's absent bill. Printed beside the figure, never inside it.
651
+ "cost_usd_excluded_not_money_n": unpriced_subscription_n,
652
+ "cost_list_usd_per_miss": round(
653
+ sum(listed) / len(listed), 4) if len(listed) >= MIN_N else None,
654
+ "cost_list_usd_records": len(listed),
432
655
  }
433
656
 
434
657
 
@@ -455,7 +678,7 @@ def analyse_phases(runs):
455
678
  confident fan-out figures largely composed of runs that could not have
456
679
  seen a subagent. Tree-scope only, with the exclusion printed.
457
680
 
458
- 3. DOLLARS. Never pooled across harness (SCHEMA.md §4). Claude and Codex
681
+ 3. DOLLARS. Never pooled across harness (SCHEMA.md §4). Claude
459
682
  carry cost_usd:null permanently; a sum over mixed records under-reports
460
683
  silently. Reported per harness or not at all.
461
684
 
@@ -464,6 +687,41 @@ def analyse_phases(runs):
464
687
  when someone asks how it was measured."""
465
688
  live = [r for r in runs if r.get("kind", "run") == "run" and not r.get("backfilled")]
466
689
 
690
+ # A run that carries both ends but no `duration_s` was silently worth ZERO TIME here,
691
+ # while its tokens still counted — so a phase's total time was a sum over some of its
692
+ # runs and its tokens a sum over others (found 2026-09-07: the reset's own 13 runs
693
+ # reported 16h49m, because 6 of them predate the field and the true figure is ~55h).
694
+ # Deriving it is arithmetic on two recorded facts, not a guess, so it is done — and
695
+ # counted, so the report can say how many of its minutes were derived rather than read.
696
+ # `ended` itself may be absent on an older record; SCHEMA.md's own rule is that `ended`
697
+ # IS the moment the record was written, which is exactly what `ts` holds.
698
+ def _secs(a, b):
699
+ try:
700
+ fmt = "%Y-%m-%dT%H:%M:%SZ"
701
+ return int((datetime.strptime(b, fmt) - datetime.strptime(a, fmt)).total_seconds())
702
+ except Exception:
703
+ return None
704
+
705
+ # A stored duration of zero or less is not a measurement: it is an impossible record
706
+ # (`ended` before `started`) that reached the stream before the emitter refused those.
707
+ # Treat it as absent so it is re-derived here, and -- when the timestamps are just as
708
+ # impossible -- excluded from every figure below rather than admitted by a truthiness
709
+ # test. tests/regression/run.sh tf_015.
710
+ derived_n = 0
711
+ negative_durations = sum(1 for r in live
712
+ if isinstance(r.get("duration_s"), (int, float)) and r["duration_s"] < 0)
713
+ for r in live:
714
+ if _dur(r) is not None or not r.get("started"):
715
+ continue
716
+ end = r.get("ended") or r.get("ts")
717
+ if not end:
718
+ continue
719
+ d = _secs(r["started"], end)
720
+ if d is not None and d >= 0:
721
+ r["duration_s"] = d
722
+ r["duration_derived"] = "ended" if r.get("ended") else "ts"
723
+ derived_n += 1
724
+
467
725
  def scope_of(r):
468
726
  return r.get("tokens_scope") or "absent"
469
727
 
@@ -481,16 +739,28 @@ def analyse_phases(runs):
481
739
  return scope_of(r) == "tree" and r.get("subagent_runs") is not None
482
740
 
483
741
  grand_out = sum(r.get("tokens_out") or 0 for r in live if has_tokens(r))
484
- grand_dur = sum(r.get("duration_s") or 0 for r in live if r.get("duration_s"))
742
+ grand_dur = sum(_dur(r) or 0 for r in live if _dur(r) is not None)
485
743
 
486
744
  phases = OrderedDict()
487
745
  for cmd in sorted({r.get("cmd") or "?" for r in live}):
488
746
  rs = [r for r in live if (r.get("cmd") or "?") == cmd]
489
747
  priced = [r for r in rs if has_tokens(r)]
490
- durs = [r["duration_s"] for r in rs if r.get("duration_s")]
748
+ durs = [_dur(r) for r in rs if _dur(r) is not None]
491
749
  fan = [r for r in rs if sees_subagents(r)]
492
750
 
493
751
  models = defaultdict(lambda: {"runs": 0, "tokens_out": 0})
752
+ # List price, COMPUTED HERE from the tokens the record carries (see list_price above —
753
+ # pricing is a reporting job, and nothing priced is ever stored). Per model, attributed
754
+ # only where the window ran ONE model, the same rule the measured dollars follow below.
755
+ list_of = {}
756
+ for r in priced:
757
+ lp = list_price(REPO_OF.get(id(r), ""), r)
758
+ if lp is None:
759
+ continue
760
+ list_of[id(r)] = lp
761
+ ms = r.get("models") or ([r["model"]] if r.get("model") else [])
762
+ if len(ms) == 1:
763
+ models[ms[0]]["list_usd"] = round(models[ms[0]].get("list_usd", 0.0) + lp, 6)
494
764
  for r in priced:
495
765
  split = r.get("model_tokens_out")
496
766
  if isinstance(split, dict) and split:
@@ -517,12 +787,42 @@ def analyse_phases(runs):
517
787
  sub_out = sum(r.get("tokens_out_subagents") or 0 for r in fan)
518
788
  fan_out_total = sum(r.get("tokens_out") or 0 for r in fan if has_tokens(r))
519
789
 
790
+ # DOLLARS, and what each one means. A harness split was never enough: one OpenCode
791
+ # machine mixes a flat-fee subscription (whose marginal cost is a true zero), a metered
792
+ # API key (whose dollars are real money) and a local model (which has no bill at all),
793
+ # and pooling those three produces a money figure that is neither the money spent nor
794
+ # the effort used. `billing_mode` is the separation; only `metered` is money.
520
795
  cost_by_harness = defaultdict(float)
521
796
  cost_n = Counter()
797
+ cost_by_mode = defaultdict(float)
798
+ mode_n = Counter()
799
+ model_usd = defaultdict(float)
800
+ model_usd_n = Counter()
801
+ plan_by_model = defaultdict(float)
802
+ usd_unattributed, usd_unattributed_n = 0.0, 0
522
803
  for r in priced:
804
+ mode = r.get("billing_mode") or "unrecorded"
805
+ mode_n[mode] += 1
523
806
  if r.get("cost_usd") is not None:
524
807
  cost_by_harness[r.get("harness") or "?"] += r["cost_usd"]
525
808
  cost_n[r.get("harness") or "?"] += 1
809
+ cost_by_mode[mode] += r["cost_usd"]
810
+ if mode == "plan":
811
+ ms = r.get("models") or ([r["model"]] if r.get("model") else [])
812
+ if len(ms) == 1:
813
+ plan_by_model[ms[0]] += r["cost_usd"]
814
+ if mode == "metered":
815
+ # Per-model spend is attributed ONLY where the window ran one model.
816
+ # Splitting one window's dollars across several models by their token
817
+ # share is arithmetic, not measurement — the same line §5.5.3 draws
818
+ # between `sole` and `shared`, one stream over.
819
+ ms = r.get("models") or ([r["model"]] if r.get("model") else [])
820
+ if len(ms) == 1:
821
+ model_usd[ms[0]] += r["cost_usd"]
822
+ model_usd_n[ms[0]] += 1
823
+ else:
824
+ usd_unattributed += r["cost_usd"]
825
+ usd_unattributed_n += 1
526
826
 
527
827
  tok = lambda k: sum(r.get(k) or 0 for r in priced)
528
828
  phases[cmd] = {
@@ -532,6 +832,10 @@ def analyse_phases(runs):
532
832
  "median": median(durs),
533
833
  "max": max(durs) if durs else None,
534
834
  "n": len(durs),
835
+ # how many of those minutes were computed from the record's own two
836
+ # timestamps because the record predates `duration_s` — arithmetic on
837
+ # recorded facts, but the reader is told rather than left to assume.
838
+ "derived_n": sum(1 for r in rs if r.get("duration_derived")),
535
839
  },
536
840
  "share_of_duration": pct(sum(durs), grand_dur),
537
841
  "tokens_measured_n": len(priced),
@@ -549,6 +853,27 @@ def analyse_phases(runs):
549
853
  key=lambda kv: (-kv[1]["tokens_out"], kv[0]))),
550
854
  "harnesses": OrderedDict(sorted(Counter(r.get("harness") or "?" for r in rs).items())),
551
855
  "modes": OrderedDict(sorted(Counter(r.get("mode") or "—" for r in rs).items())),
856
+ # A phase run in named modes (a reset's sessions, a build's fresh/fix passes)
857
+ # is several different jobs under one `cmd`. The per-mode split says which,
858
+ # in the order they happened. Same denominators as the phase above: a run
859
+ # with no computable window contributes no tokens, never a zero.
860
+ "by_mode": OrderedDict(
861
+ (mode, {
862
+ "runs": len([r for r in rs if (r.get("mode") or "—") == mode]),
863
+ "duration_s": sum(_dur(r) or 0
864
+ for r in rs if (r.get("mode") or "—") == mode),
865
+ "tokens_out": sum(r.get("tokens_out") or 0
866
+ for r in rs if (r.get("mode") or "—") == mode and has_tokens(r)),
867
+ "tokens_unmeasured_n": len([r for r in rs
868
+ if (r.get("mode") or "—") == mode and not has_tokens(r)]),
869
+ "files_written": sum(r.get("files_written") or 0
870
+ for r in rs if (r.get("mode") or "—") == mode),
871
+ "first_started": min((r.get("started") or "" for r in rs
872
+ if (r.get("mode") or "—") == mode), default=""),
873
+ })
874
+ for mode in sorted({(r.get("mode") or "—") for r in rs},
875
+ key=lambda m: min((r.get("started") or "" for r in rs
876
+ if (r.get("mode") or "—") == m), default=""))),
552
877
  "build_result": OrderedDict(sorted(Counter(r.get("build_result") or "—" for r in rs).items())),
553
878
  "reqs_touched_total": sum(r.get("reqs_count") or 0 for r in rs),
554
879
  "files_written_total": sum(r.get("files_written") or 0 for r in rs),
@@ -580,6 +905,36 @@ def analyse_phases(runs):
580
905
  "cost_usd_by_harness": OrderedDict(
581
906
  (h, {"usd": round(v, 6), "records": cost_n[h]})
582
907
  for h, v in sorted(cost_by_harness.items())),
908
+ # …and per billing mode, which is the separation that decides whether a number
909
+ # is money at all (SCHEMA.md §2.5b).
910
+ "billing_modes": OrderedDict(sorted(mode_n.items())),
911
+ "cost_usd_by_billing_mode": OrderedDict(
912
+ (mode, round(v, 6)) for mode, v in sorted(cost_by_mode.items())),
913
+ "money_usd": round(cost_by_mode.get("metered", 0.0), 6),
914
+ "money_records": mode_n.get("metered", 0),
915
+ # A window that mixed a metered model with a subscription one really was billed, but
916
+ # the bill cannot be separated from the part that was already paid for. Real money,
917
+ # reported on its own line rather than folded into the headline or dropped.
918
+ "money_mixed_usd": round(cost_by_mode.get("mixed", 0.0), 6),
919
+ "money_mixed_records": mode_n.get("mixed", 0),
920
+ "spend_by_model": OrderedDict(
921
+ (m, {"usd": round(v, 6), "records": model_usd_n[m]})
922
+ for m, v in sorted(model_usd.items(), key=lambda kv: (-kv[1], kv[0]))),
923
+ "spend_unattributed": {"usd": round(usd_unattributed, 6), "records": usd_unattributed_n},
924
+ # LIST PRICE — what these tokens would cost bought at the published rate. It is the
925
+ # only figure that compares a subscription phase with a metered one, and it is a
926
+ # price list applied to a measurement, never a bill. Reported apart from `money_usd`
927
+ # for exactly that reason, with the unpriced count beside it.
928
+ "list_usd": round(sum(list_of.values()), 6),
929
+ "list_usd_records": len(list_of),
930
+ "list_usd_unpriced_n": len(priced) - len(list_of),
931
+ # A monthly plan reports dollars too, but they are ALLOWANCE CONSUMED against a
932
+ # per-model limit, not an invoice — and they are the numbers that produce the limit
933
+ # the fallback chain exists for, so they are worth their own line.
934
+ "plan_allowance_usd": round(cost_by_mode.get("plan", 0.0), 6),
935
+ "plan_allowance_records": mode_n.get("plan", 0),
936
+ "plan_allowance_by_model": OrderedDict(
937
+ (m, round(v, 6)) for m, v in sorted(plan_by_model.items(), key=lambda kv: -kv[1])),
583
938
  }
584
939
 
585
940
  return {
@@ -587,6 +942,12 @@ def analyse_phases(runs):
587
942
  "scope_coverage": OrderedDict(sorted(scope_cov.items())),
588
943
  "tokens_out_total": grand_out,
589
944
  "duration_s_total": grand_dur,
945
+ # What the duration figures are actually built on. A total is only honest when the
946
+ # reader is told what it left out: TechieBlog's 46 run records yield 32 usable ones.
947
+ "duration_measured_n": len([r for r in live if _dur(r) is not None]),
948
+ "duration_impossible_n": _dur_quality(live)[0],
949
+ "duration_absent_n": _dur_quality(live)[1],
950
+ "duration_recomputed_n": _dur_quality(live)[2],
590
951
  "phases": phases,
591
952
  "note": ("Per-phase effort over LIVE run records, aggregated by `cmd`. Token "
592
953
  "figures exclude runs whose window could not be computed; fan-out "
@@ -879,10 +1240,15 @@ def analyse(repos):
879
1240
  per_repo = []
880
1241
  commit_dupes = 0
881
1242
  session_dupes = 0
1243
+ voided_runs = 0
1244
+ void_reasons = []
1245
+ void_orphans = 0
882
1246
  for repo in repos:
883
1247
  g = read_stream(repo, "gates")
884
- r = read_stream(repo, "runs")
885
- misses += read_stream(repo, "misses")
1248
+ r, vn, vr, vo = apply_voids(read_stream(repo, "runs"), app_name(repo))
1249
+ voided_runs += vn; void_reasons += vr; void_orphans += vo
1250
+ repo_misses = read_stream(repo, "misses")
1251
+ misses += repo_misses
886
1252
  # Per repo, not across them (SCHEMA.md §4): the OpenCode plugin
887
1253
  # appends a cumulative snapshot at every root-session idle.
888
1254
  s, sd = dedupe_sessions(read_stream(repo, "sessions"))
@@ -890,6 +1256,11 @@ def analyse(repos):
890
1256
  # Per repo, not across them: two repos may legitimately share a short sha.
891
1257
  c, d = dedupe_commits(read_stream(repo, "commits"))
892
1258
  commit_dupes += d
1259
+ # Which repo each record came from, so the report can price it with THAT project's rate
1260
+ # card and routing. Kept beside the records rather than written into them: nothing here
1261
+ # may add a field a record did not have.
1262
+ for _x in r + repo_misses:
1263
+ REPO_OF[id(_x)] = repo
893
1264
  gates += g; runs += r; sessions += s; commits += c
894
1265
  per_repo.append({"repo": repo, "app": app_name(repo),
895
1266
  "project_type": project_type(repo)[0],
@@ -910,39 +1281,73 @@ def analyse(repos):
910
1281
  "commit_hook": has_commit_hook(repo)})
911
1282
 
912
1283
  def seg(records):
1284
+ # An `FR` verdict grades a framework requirement line, not an application's
1285
+ # screen: it is written by a runner over a document the framework controls, so
1286
+ # it passes far more often than a REQ verified against a running app. Pooled
1287
+ # into an application segment it raises that segment's first-pass rate and
1288
+ # dilutes its escape rate, with nothing on the output to show it happened.
1289
+ # SCHEMA.md §3 states the rule -- "it never pools with the others" -- and this
1290
+ # is where it is applied. Tested by tests/regression/run.sh tf_020.
913
1291
  d = defaultdict(list)
914
1292
  for r in records:
915
- key = "unclassified" if r.get("project_type_inferred") else r.get("project_type", "app")
1293
+ if r.get("req_class") == "FR":
1294
+ key = "framework-requirement"
1295
+ else:
1296
+ key = "unclassified" if r.get("project_type_inferred") else r.get("project_type", "app")
916
1297
  d[key].append(r)
917
1298
  return d
918
1299
 
919
1300
  live = [g for g in gates if not g.get("backfilled")]
920
1301
  back = [g for g in gates if g.get("backfilled")]
921
1302
 
1303
+ # `attempt` is DEFINED by §3.1 as 1 + the number of prior live gate records for the same
1304
+ # requirement in the same app — a count over this very stream, not a judgement. A record
1305
+ # that omits it was therefore being read as "no attempt", which drops it out of the
1306
+ # first-pass rate and reports 0% for a set of records that all passed on their first and
1307
+ # only verdict (found 2026-09-07 on the framework's own requirement lines). Derived here,
1308
+ # in stream order, for records that lack it. A record that carries one is never touched.
1309
+ _seen = {}
1310
+ for g in sorted(live, key=lambda r: (r.get("ts") or "", r.get("run_id") or "")):
1311
+ key = (g.get("app"), g.get("req_id"))
1312
+ _seen[key] = _seen.get(key, 0) + 1
1313
+ if g.get("attempt") is None:
1314
+ g["attempt"] = _seen[key]
1315
+ g["attempt_derived"] = True
1316
+
922
1317
  # REQs with ANY backfilled record are excluded from the live first-pass rate:
923
1318
  # their live `attempt` numbering restarts at 1 (SCHEMA.md §3.1).
924
- tainted = {g.get("req_id") for g in back}
1319
+ # A requirement is keyed by (app, req_id): REQ-UI-001 exists in every project, so a rollup that
1320
+ # keyed on req_id alone counted TfLens's and TechieBlog's as one requirement and its first-pass
1321
+ # rate was wrong (MISS-TechieFlow-20260907-03, Session 5).
1322
+ def rk(r):
1323
+ return (r.get("app"), r.get("req_id"))
1324
+
1325
+ tainted = {rk(g) for g in back}
925
1326
 
926
- out = {"per_repo": per_repo, "tainted_reqs": sorted(x for x in tainted if x),
1327
+ out = {"per_repo": per_repo, "tainted_reqs": sorted("%s:%s" % x for x in tainted if x[1]),
927
1328
  "live": {}, "backfilled": {}, "pooled": {},
1329
+ # what a `run-void` took out of every figure above, and why (SCHEMA.md §2.7)
1330
+ "runs_voided_n": voided_runs,
1331
+ "runs_voided": void_reasons,
1332
+ "run_voids_orphaned_n": void_orphans,
928
1333
  "misses": analyse_misses(misses),
929
1334
  "phases": analyse_phases(runs)}
930
1335
 
931
1336
  for label, bucket in (("live", live), ("backfilled", back)):
932
1337
  for ptype, recs in sorted(seg(bucket).items()):
933
- eligible = [r for r in recs if label == "backfilled" or r.get("req_id") not in tainted]
934
- reqs = {r.get("req_id") for r in eligible}
935
- first_pass = {r.get("req_id") for r in eligible
1338
+ eligible = [r for r in recs if label == "backfilled" or rk(r) not in tainted]
1339
+ reqs = {rk(r) for r in eligible}
1340
+ first_pass = {rk(r) for r in eligible
936
1341
  if r.get("attempt") == 1 and r.get("verdict") == "Verified"}
937
1342
  failures = [r for r in recs if r.get("verdict") not in ("Verified", "Done (pre-existing)")]
938
1343
  dist = Counter(r.get("gate") or "unattributed" for r in failures)
939
- escaped_reqs = {r.get("req_id") for r in recs if r.get("gate") == "escaped"}
940
- failed_reqs = {r.get("req_id") for r in failures}
1344
+ escaped_reqs = {rk(r) for r in recs if r.get("gate") == "escaped"}
1345
+ failed_reqs = {rk(r) for r in failures}
941
1346
  out[label][ptype] = {
942
1347
  "records": len(recs),
943
1348
  "reqs_scored": len(reqs),
944
1349
  "reqs_excluded_backfill_taint":
945
- len({r.get("req_id") for r in recs if r.get("req_id") in tainted})
1350
+ len({rk(r) for r in recs if rk(r) in tainted})
946
1351
  if label == "live" else 0,
947
1352
  "first_pass_n": len(first_pass),
948
1353
  "first_pass_rate": pct(len(first_pass), len(reqs)) if len(reqs) >= MIN_N else "insufficient data (n=%d)" % len(reqs),
@@ -963,10 +1368,12 @@ def analyse(repos):
963
1368
  # ---- poolable metrics: runs, cadence, tokens. Exempt from both separations.
964
1369
  build_runs = [r for r in runs if r.get("cmd") == "build-phase"]
965
1370
  fix_runs = [r for r in runs if r.get("mode") == "fix"]
966
- throughput = [float(r["reqs_count"]) / r["duration_s"]
967
- for r in runs if r.get("duration_s") and r.get("reqs_count")]
1371
+ throughput = [float(r["reqs_count"]) / _dur(r)
1372
+ for r in runs if _dur(r) is not None and r.get("reqs_count")]
968
1373
  batch = [r["reqs_count"] for r in build_runs if r.get("reqs_count") is not None]
969
1374
  verified_transitions = sum(1 for g in gates if g.get("verdict") == "Verified")
1375
+ pooled_list = [p for p in (list_price(REPO_OF.get(id(r), ""), r) for r in runs)
1376
+ if p is not None]
970
1377
  tok = sum((s.get("input_tokens") or 0) + (s.get("output_tokens") or 0) for s in sessions)
971
1378
  days = {c.get("ts", "")[:10] for c in commits if c.get("ts")}
972
1379
  out["pooled"] = {
@@ -980,7 +1387,16 @@ def analyse(repos):
980
1387
  "tokens_total": tok,
981
1388
  "tokens_per_verified_req": round(float(tok) / verified_transitions, 1)
982
1389
  if verified_transitions >= MIN_N and tok else None,
983
- "cost_usd": None, # never estimated — see SCHEMA.md §4
1390
+ "cost_usd": None, # what was BILLED is never estimated — see SCHEMA.md §4
1391
+ # What the same work costs at list price. A price, not a bill (SCHEMA.md §2.5b), and the
1392
+ # only money-shaped headline that works on a subscription — which is most of this
1393
+ # framework's runs. Summed over the runs that carry it, with that count published, so a
1394
+ # figure built on half the runs cannot be read as if it covered all of them.
1395
+ "list_usd_total": round(sum(pooled_list), 4),
1396
+ "list_usd_records": len(pooled_list),
1397
+ "list_usd_per_verified_req": (
1398
+ round(sum(pooled_list) / verified_transitions, 4)
1399
+ if verified_transitions >= MIN_N and len(pooled_list) >= MIN_N else None),
984
1400
  "commits": len(commits),
985
1401
  "commit_duplicates_collapsed": commit_dupes,
986
1402
  "session_duplicates_collapsed": session_dupes,
@@ -1067,6 +1483,20 @@ def print_report(a, repos):
1067
1483
  print(" across project_type or across live/backfilled would produce a")
1068
1484
  print(" number that cannot be defended. See SCHEMA.md §6.")
1069
1485
  print("")
1486
+ if a.get("runs_voided_n"):
1487
+ print(" %d run record(s) VOIDED — named by a run-void record as wrong, so they are"
1488
+ % a["runs_voided_n"])
1489
+ print(" in no figure above. Both records stay on the stream; nothing was deleted:")
1490
+ for line in a.get("runs_voided", [])[:6]:
1491
+ print(" " + line)
1492
+ if len(a.get("runs_voided", [])) > 6:
1493
+ print(" ... and %d more" % (len(a["runs_voided"]) - 6))
1494
+ print("")
1495
+ if a.get("run_voids_orphaned_n"):
1496
+ print(" %d run-void record(s) name a run that is not on this stream — the void was"
1497
+ % a["run_voids_orphaned_n"])
1498
+ print(" written against a record that never arrived. Nothing was excluded for them.")
1499
+ print("")
1070
1500
  if a["tainted_reqs"]:
1071
1501
  print(" %d REQ(s) excluded from the LIVE first-pass rate because they carry"
1072
1502
  % len(a["tainted_reqs"]))
@@ -1087,8 +1517,14 @@ def print_report(a, repos):
1087
1517
  print(" REQ throughput : %s REQs/hour (median across runs)" %
1088
1518
  (p["throughput_median_reqs_per_hour"] if p["throughput_median_reqs_per_hour"] is not None else "insufficient data"))
1089
1519
  print(" sessions / tokens : %d sessions, %s total tokens" % (p["sessions"], "{:,}".format(p["tokens_total"])))
1090
- print(" tokens per Verified : %s (cost in USD is NEVER estimated — SCHEMA.md §4)" %
1520
+ print(" tokens per Verified : %s (what you were BILLED is never estimated — SCHEMA.md §4)" %
1091
1521
  (p["tokens_per_verified_req"] if p["tokens_per_verified_req"] is not None else "insufficient data"))
1522
+ if p.get("list_usd_records"):
1523
+ print(" list price : $%s over %d priced run(s) — the published rate applied to"
1524
+ % (p["list_usd_total"], p["list_usd_records"]))
1525
+ print(" the tokens actually measured. A price, not a bill.")
1526
+ if p.get("list_usd_per_verified_req") is not None:
1527
+ print(" list $ per Verified : $%s" % p["list_usd_per_verified_req"])
1092
1528
  print(" commit cadence : %s commits/active day (%d commits over %d days)" %
1093
1529
  (p["commits_per_active_day"] if p["commits_per_active_day"] is not None else "—",
1094
1530
  p["commits"], p["active_days"]))
@@ -1137,6 +1573,15 @@ def print_phases(p, W):
1137
1573
  print("-" * W)
1138
1574
  print(" %d live run record(s). Token-window coverage: %s"
1139
1575
  % (p["runs_live"], " ".join("%s=%d" % kv for kv in p["scope_coverage"].items())))
1576
+ if any(p.get(k) for k in ("duration_impossible_n", "duration_absent_n", "duration_recomputed_n")):
1577
+ print(" duration: %d of %d record(s) usable"
1578
+ % (p.get("duration_measured_n", 0), p["runs_live"])
1579
+ + (", %d impossible (start after end, discarded)" % p["duration_impossible_n"]
1580
+ if p.get("duration_impossible_n") else "")
1581
+ + (", %d record no elapsed time" % p["duration_absent_n"]
1582
+ if p.get("duration_absent_n") else "")
1583
+ + (", %d taken from the timestamps over a stored figure that disagreed"
1584
+ % p["duration_recomputed_n"] if p.get("duration_recomputed_n") else ""))
1140
1585
  print(" A window is only as good as its scope: 'tree' saw the subagents, 'main'")
1141
1586
  print(" did not look, 'none'/absent measured nothing at all and is excluded from")
1142
1587
  print(" every token figure below rather than averaged in as a zero.")
@@ -1154,8 +1599,9 @@ def print_phases(p, W):
1154
1599
  for cmd, m in p["phases"].items():
1155
1600
  print(" [%s] %d run(s)" % (cmd, m["runs"]))
1156
1601
  d = m["duration_s"]
1157
- print(" wall clock : total %s · median %s · max %s (n=%d timed)"
1158
- % (_hms(d["total"]), _hms(d["median"]), _hms(d["max"]), d["n"]))
1602
+ print(" wall clock : total %s · median %s · max %s (n=%d timed%s)"
1603
+ % (_hms(d["total"]), _hms(d["median"]), _hms(d["max"]), d["n"],
1604
+ ", %d derived from the record's own timestamps" % d["derived_n"] if d.get("derived_n") else ""))
1159
1605
  t = m["tokens"]
1160
1606
  print(" tokens : out %s · in %s · cache-read %s · cache-write %s"
1161
1607
  % (_k(t["out"]), _k(t["in"]), _k(t["cache_read"]), _k(t["cache_write"])))
@@ -1216,8 +1662,65 @@ def print_phases(p, W):
1216
1662
  for h, c in m["cost_usd_by_harness"].items():
1217
1663
  print(" cost (%s) : $%s over %d record(s) — MEASURED, never pooled "
1218
1664
  "across harness" % (h, c["usd"], c["records"]))
1219
- if m["reqs_touched_total"] or m["files_written_total"]:
1220
- print(" work : %d REQ touch(es) · %d file(s) written"
1665
+ bmodes = m.get("billing_modes") or {}
1666
+ if bmodes:
1667
+ print(" paid for by : " +
1668
+ " · ".join("%s %d" % (k, v) for k, v in bmodes.items()))
1669
+ if m.get("list_usd_records"):
1670
+ print(" list price : $%s over %d run(s) — the published rate applied to"
1671
+ % (m["list_usd"], m["list_usd_records"]))
1672
+ print(" the tokens on the record, worked out HERE and never")
1673
+ print(" stored. A price, not a bill: it is what makes a")
1674
+ print(" subscription run comparable with a metered one.")
1675
+ if m.get("list_usd_unpriced_n"):
1676
+ print(" %d run(s) had no rate for their model and are"
1677
+ % m["list_usd_unpriced_n"])
1678
+ print(" outside it — never priced at zero.")
1679
+ if m.get("money_records"):
1680
+ print(" money billed : $%s over %d metered run(s). Only a metered run is"
1681
+ % (m["money_usd"], m["money_records"]))
1682
+ print(" money billed; a subscription's zero is a flat fee")
1683
+ print(" already paid, a local model has no bill (§2.5b).")
1684
+ if m.get("plan_allowance_records"):
1685
+ print(" plan allowance : $%s used over %d run(s) on a monthly plan. This is"
1686
+ % (m["plan_allowance_usd"], m["plan_allowance_records"]))
1687
+ print(" the provider's own meter against a PER-MODEL")
1688
+ print(" allowance, not an invoice — and it is what runs out")
1689
+ print(" and sends a tier to its fallback.")
1690
+ for mod, v in (m.get("plan_allowance_by_model") or {}).items():
1691
+ print(" %-30s $%s of allowance" % (mod, v))
1692
+ if m.get("money_mixed_usd"):
1693
+ print(" also billed : $%s on %d run(s) whose window mixed billing modes —"
1694
+ % (m["money_mixed_usd"], m["money_mixed_records"]))
1695
+ print(" real money, but not separable from the part a")
1696
+ print(" subscription had already paid for.")
1697
+ for mod, c in (m.get("models") or {}).items():
1698
+ bits = []
1699
+ if c.get("list_usd"):
1700
+ bits.append("list $%s" % c["list_usd"])
1701
+ sp = (m.get("spend_by_model") or {}).get(mod)
1702
+ if sp:
1703
+ bits.append("billed $%s" % sp["usd"])
1704
+ if bits:
1705
+ print(" %-30s %s" % (mod, " · ".join(bits)))
1706
+ un = m.get("spend_unattributed") or {}
1707
+ if un.get("records"):
1708
+ print(" %-30s billed $%-8s over %d run(s) — the window ran several"
1709
+ % ("(not attributed)", un["usd"], un["records"]))
1710
+ print(" models, and one window's dollars are not split by token share.")
1711
+ # `bm` used to be assigned inside `if m["reqs_touched_total"] or m["files_written_total"]:`
1712
+ # and read outside it, so --phases crashed with UnboundLocalError on any phase that
1713
+ # touched no REQ and wrote no file — which is every metrics-report and every render.
1714
+ # MISS-TechieFlow-20260910-03.
1715
+ bm = m.get("by_mode") or {}
1716
+ if len(bm) > 1:
1717
+ print(" by mode : (in the order they ran)")
1718
+ for mode, v in bm.items():
1719
+ unm = (" [%d unmeasured]" % v["tokens_unmeasured_n"]) if v["tokens_unmeasured_n"] else ""
1720
+ print(" %-22s %2d run(s) %8s %8s out %3d file(s)%s"
1721
+ % (mode, v["runs"], _hms(v["duration_s"]), _k(v["tokens_out"]),
1722
+ v["files_written"], unm))
1723
+ print(" work : %d REQ touch(es) · %d file(s) written"
1221
1724
  % (m["reqs_touched_total"], m["files_written_total"]))
1222
1725
  print("")
1223
1726
 
@@ -1225,7 +1728,7 @@ def print_phases(p, W):
1225
1728
  def print_misses(m, W):
1226
1729
  """The §5.5 block. Two exclusions are printed with the figures they bound —
1227
1730
  an exclusion the reader cannot see is indistinguishable from a bug."""
1228
- if not m["misses_total"] and not m["miss_fixes_total"]:
1731
+ if not m["misses_total"] and not m["miss_fixes_total"] and not m.get("reviews_n"):
1229
1732
  return
1230
1733
  print("")
1231
1734
  print("-" * W)
@@ -1269,9 +1772,29 @@ def print_misses(m, W):
1269
1772
  % m["escapes_missing_why"])
1270
1773
  print(" nothing recorded why. That is the most valuable record in the stream.")
1271
1774
  print(" Complete it: tf-emit.sh --amend <miss_id> why_missed <value> (§5.5.7)")
1775
+ # whose gap — the four-question sort (§5.5.10). Its own denominator, like why_missed.
1776
+ if m.get("sort"):
1777
+ print(" whose gap : (%d of %d misses sorted)" % (m["sort_n"], m["sort_eligible"]))
1778
+ for k, n in m["sort"].items():
1779
+ print(" %-12s %4d %-5s %s" % (k, n, pct(n, m["sort_n"]), SORT_WORDS.get(k, "")))
1780
+ if m.get("sort_predates_field"):
1781
+ print(" %d miss(es) predate the sort field (added %s); sort them with"
1782
+ % (m["sort_predates_field"], FIELD_SINCE["sort"]))
1783
+ print(" tf-emit.sh --amend <miss_id> sort <spec|unsaid|weak-check|ignored>")
1272
1784
  if m["amendments_applied"]:
1273
1785
  print(" amendments folded : %d field(s) completed by miss-amend records (§5.5.7)"
1274
1786
  % m["amendments_applied"])
1787
+ # owner reviews (§5.5.9): corrections per reviewed phase, and the two costs
1788
+ if m.get("reviews_n"):
1789
+ print(" owner reviews : %d review(s), %d correction(s) given — "
1790
+ % (m["reviews_n"], m["review_corrections"])
1791
+ + " ".join("%s=%d" % kv for kv in m["reviews_by_phase"].items()))
1792
+ print(" tokens to produce the reviewed output : %s (n=%d)"
1793
+ % (m["review_tokens_produce_mean"] if m["review_tokens_produce_mean"] is not None
1794
+ else "insufficient data", m["review_tokens_produce_n"]))
1795
+ print(" tokens to apply the corrections : %s (n=%d)"
1796
+ % (m["review_tokens_correct_mean"] if m["review_tokens_correct_mean"] is not None
1797
+ else "insufficient data", m["review_tokens_correct_n"]))
1275
1798
  if m["orphan_amends"]:
1276
1799
  print(" ⚠ %d miss-amend record(s) name no known miss, or a field outside the"
1277
1800
  % m["orphan_amends"])
@@ -1315,9 +1838,18 @@ def print_misses(m, W):
1315
1838
  else:
1316
1839
  print(" USD per miss : no measured dollars (%d priced records)"
1317
1840
  % m["cost_usd_records"])
1318
- print(" Claude Code and Codex carry cost_usd:null permanently and are NEVER")
1841
+ print(" Claude Code carries cost_usd:null permanently and is NEVER")
1319
1842
  print(" priced from a rate card here (SCHEMA.md §4). Real dollars come from")
1320
1843
  print(" OpenCode runs; token counts are the honest figure everywhere else.")
1844
+ if m.get("cost_list_usd_per_miss") is not None:
1845
+ print(" list price per miss : $%s (%d record(s)) — the repair's tokens at the"
1846
+ % (m["cost_list_usd_per_miss"], m["cost_list_usd_records"]))
1847
+ print(" published rate. Works on every harness, and is a price, not a bill.")
1848
+ if m.get("cost_usd_excluded_not_money_n"):
1849
+ print(" %d fix record(s) measured a cost that is NOT money — a subscription's"
1850
+ % m["cost_usd_excluded_not_money_n"])
1851
+ print(" zero or a local model's absent bill — and are outside that figure")
1852
+ print(" rather than averaged in as free repairs (SCHEMA.md §2.5b).")
1321
1853
  print(" shared (apportioned): %d fix records — equal division, NOT a measurement"
1322
1854
  % m["cost_shared_n"])
1323
1855
  print(" tokens out per miss : %s (n=%d priced)" %