@techierathore/techieflow 1.0.3 → 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 (95) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +5 -8
  2. package/.claude/commands/TechieFlow/agents/architect.md +1 -10
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
  4. package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
  5. package/.claude/commands/TechieFlow/tasks/_owner-language.md +26 -0
  6. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
  7. package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
  8. package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
  9. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
  10. package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
  11. package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
  12. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +2 -2
  14. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
  15. package/.claude/commands/TechieFlow/tasks/generate-html.md +5 -1
  16. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +18 -16
  17. package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
  18. package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
  19. package/.opencode/plugin/techieflow.js +2 -2
  20. package/.tfcore/agents/analyst.md +5 -8
  21. package/.tfcore/agents/architect.md +1 -10
  22. package/.tfcore/agents/flow-master.md +1 -0
  23. package/.tfcore/agents/verifier.md +1 -0
  24. package/.tfcore/hooks/guard-metrics.sh +13 -1
  25. package/.tfcore/hooks/guard-verify-deps.sh +124 -0
  26. package/.tfcore/routing.yaml +61 -0
  27. package/.tfcore/tasks/_owner-language.md +26 -0
  28. package/.tfcore/tasks/_smoke-test-policy.md +1 -1
  29. package/.tfcore/tasks/amend-docs.md +6 -5
  30. package/.tfcore/tasks/build-phase.md +1 -1
  31. package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
  32. package/.tfcore/tasks/create-doc.md +4 -0
  33. package/.tfcore/tasks/create-project-brief.md +22 -0
  34. package/.tfcore/tasks/day1-brownfield.md +2 -2
  35. package/.tfcore/tasks/day1-greenfield.md +2 -2
  36. package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
  37. package/.tfcore/tasks/generate-html.md +5 -1
  38. package/.tfcore/tasks/handoff-phase.md +18 -16
  39. package/.tfcore/tasks/metrics-report.md +2 -0
  40. package/.tfcore/tasks/verify-phase.md +1 -1
  41. package/.tfcore/telemetry/SCHEMA.md +57 -1
  42. package/.tfcore/telemetry/tf-metrics.sh +416 -19
  43. package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
  44. package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
  45. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
  46. package/.tfcore/templates/v4custom/html-render-shell.md +1 -1
  47. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  48. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  49. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  50. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  51. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  52. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  53. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  54. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  55. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  56. package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
  57. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  58. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  59. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  60. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  61. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  62. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  63. package/.tfcore/utils/tf-build-list.py +23 -3
  64. package/.tfcore/utils/tf-build-list.sh +3 -2
  65. package/.tfcore/utils/tf-doc-check.py +106 -4
  66. package/.tfcore/utils/tf-emit.sh +353 -16
  67. package/.tfcore/utils/tf-fix-close.sh +34 -2
  68. package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
  69. package/.tfcore/utils/tf-goal.sh +93 -6
  70. package/.tfcore/utils/tf-misses-md.py +14 -17
  71. package/.tfcore/utils/tf-misses-md.sh +5 -4
  72. package/.tfcore/utils/tf-model-pick.sh +475 -0
  73. package/.tfcore/utils/tf-phase.sh +18 -0
  74. package/.tfcore/utils/tf-render-html.py +20 -0
  75. package/.tfcore/utils/tf-routing-bind.sh +87 -1
  76. package/.tfcore/utils/tf-routing.sh +7 -0
  77. package/.tfcore/utils/tf-selfcheck.sh +323 -0
  78. package/.tfcore/utils/tf-split-brd.py +6 -1
  79. package/.tfcore/utils/tf-status-facts.py +61 -12
  80. package/.tfcore/utils/tf-verify-screens.mjs +85 -13
  81. package/.tfcore/utils/tf-verify-screens.sh +12 -3
  82. package/.tfcore/utils/tf-verify-tests.sh +60 -17
  83. package/.tfcore/utils/tf-verify-verdict.py +11 -2
  84. package/README.md +15 -5
  85. package/opencode.jsonc +24 -0
  86. package/package.json +1 -1
  87. package/scripts/install.mjs +4 -0
  88. package/.tfcore/templates/architecture-tmpl.yaml +0 -650
  89. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
  90. package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
  91. package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
  92. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
  93. package/.tfcore/templates/market-research-tmpl.yaml +0 -252
  94. package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
  95. package/.tfcore/utils/techieflow-doc-template.md +0 -325
@@ -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
@@ -53,12 +53,77 @@ LATE_GATES = {"perf": "2026-08-10", "assets": "2026-08-31", "mockup-parity": "20
53
53
  # fill, so counting it as "not assessed" understates the practice being measured.
54
54
  # It leaves that field's denominator and is reported separately. SCHEMA.md §5.5.6 /
55
55
  # §3.5. Keep this table in sync when an optional field is added to any stream.
56
- FIELD_SINCE = {"why_missed": "2026-08-28", "sort": "2026-09-07"}
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
57
117
 
58
118
  # Fields a `miss-amend` record may complete, and their closed vocabularies.
59
119
  # Kept identical to tf-emit.sh's `_AMENDABLE`; the emitter enforces it on write and
60
120
  # this enforces it again on read, because a merged stream can carry records this
61
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
+
62
127
  AMENDABLE_FIELDS = {
63
128
  "why_missed": ("missing-checklist-item", "insufficient-verify-method",
64
129
  "code-audit-limitation", "ambiguous-acceptance",
@@ -71,6 +136,72 @@ SORT_WORDS = {"spec": "the app's spec did not say it", "unsaid": "the framework
71
136
  MIN_N = 3 # fewer supporting records than this -> "insufficient data", never a number
72
137
 
73
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
+
74
205
  # ---------------------------------------------------------------- utilities
75
206
  def die(msg):
76
207
  sys.stderr.write("tf-metrics: %s\n" % msg)
@@ -99,6 +230,42 @@ def read_stream(repo, stream):
99
230
  return out
100
231
 
101
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
+
102
269
  def emit(repo, stream, records, dry_run, quiet=False):
103
270
  """Append via tf-emit.sh — the single append primitive. Never write JSONL directly.
104
271
 
@@ -227,11 +394,14 @@ def analyse_misses(misses):
227
394
  for a in sorted(amends, key=lambda r: r.get("ts") or ""):
228
395
  parent = opened_by_id.get(a.get("miss_id"))
229
396
  fld = a.get("field")
230
- 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):
231
398
  orphan_amends += 1
232
399
  continue
233
- if parent.get(fld) is None and a.get("value") in AMENDABLE_FIELDS[fld]:
234
- 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
235
405
  amended += 1
236
406
 
237
407
  # ---- eligibility floor for optional fields added mid-stream (SCHEMA.md §3.5,
@@ -360,7 +530,21 @@ def analyse_misses(misses):
360
530
  # Dollars exist ONLY where a harness measured them. Claude Code carries
361
531
  # cost_usd:null permanently (SCHEMA.md §4) and are never priced from a rate card
362
532
  # here — a pooled sum over mixed harnesses would silently under-report.
363
- 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]
364
548
  escaped = [m for m in opened if m.get("found_by") in ("owner", "production")]
365
549
  design = [m for m in opened if m.get("miss_class") == "unspecified-gap"]
366
550
 
@@ -462,6 +646,12 @@ def analyse_misses(misses):
462
646
  "cost_usd_per_miss_measured": round(
463
647
  sum(f["cost_usd"] for f in paid) / len(paid), 4) if len(paid) >= MIN_N else None,
464
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),
465
655
  }
466
656
 
467
657
 
@@ -512,9 +702,16 @@ def analyse_phases(runs):
512
702
  except Exception:
513
703
  return None
514
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.
515
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)
516
713
  for r in live:
517
- if r.get("duration_s") or not r.get("started"):
714
+ if _dur(r) is not None or not r.get("started"):
518
715
  continue
519
716
  end = r.get("ended") or r.get("ts")
520
717
  if not end:
@@ -542,16 +739,28 @@ def analyse_phases(runs):
542
739
  return scope_of(r) == "tree" and r.get("subagent_runs") is not None
543
740
 
544
741
  grand_out = sum(r.get("tokens_out") or 0 for r in live if has_tokens(r))
545
- 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)
546
743
 
547
744
  phases = OrderedDict()
548
745
  for cmd in sorted({r.get("cmd") or "?" for r in live}):
549
746
  rs = [r for r in live if (r.get("cmd") or "?") == cmd]
550
747
  priced = [r for r in rs if has_tokens(r)]
551
- 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]
552
749
  fan = [r for r in rs if sees_subagents(r)]
553
750
 
554
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)
555
764
  for r in priced:
556
765
  split = r.get("model_tokens_out")
557
766
  if isinstance(split, dict) and split:
@@ -578,12 +787,42 @@ def analyse_phases(runs):
578
787
  sub_out = sum(r.get("tokens_out_subagents") or 0 for r in fan)
579
788
  fan_out_total = sum(r.get("tokens_out") or 0 for r in fan if has_tokens(r))
580
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.
581
795
  cost_by_harness = defaultdict(float)
582
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
583
803
  for r in priced:
804
+ mode = r.get("billing_mode") or "unrecorded"
805
+ mode_n[mode] += 1
584
806
  if r.get("cost_usd") is not None:
585
807
  cost_by_harness[r.get("harness") or "?"] += r["cost_usd"]
586
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
587
826
 
588
827
  tok = lambda k: sum(r.get(k) or 0 for r in priced)
589
828
  phases[cmd] = {
@@ -621,7 +860,7 @@ def analyse_phases(runs):
621
860
  "by_mode": OrderedDict(
622
861
  (mode, {
623
862
  "runs": len([r for r in rs if (r.get("mode") or "—") == mode]),
624
- "duration_s": sum(r.get("duration_s") or 0
863
+ "duration_s": sum(_dur(r) or 0
625
864
  for r in rs if (r.get("mode") or "—") == mode),
626
865
  "tokens_out": sum(r.get("tokens_out") or 0
627
866
  for r in rs if (r.get("mode") or "—") == mode and has_tokens(r)),
@@ -666,6 +905,36 @@ def analyse_phases(runs):
666
905
  "cost_usd_by_harness": OrderedDict(
667
906
  (h, {"usd": round(v, 6), "records": cost_n[h]})
668
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])),
669
938
  }
670
939
 
671
940
  return {
@@ -673,6 +942,12 @@ def analyse_phases(runs):
673
942
  "scope_coverage": OrderedDict(sorted(scope_cov.items())),
674
943
  "tokens_out_total": grand_out,
675
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],
676
951
  "phases": phases,
677
952
  "note": ("Per-phase effort over LIVE run records, aggregated by `cmd`. Token "
678
953
  "figures exclude runs whose window could not be computed; fan-out "
@@ -965,10 +1240,15 @@ def analyse(repos):
965
1240
  per_repo = []
966
1241
  commit_dupes = 0
967
1242
  session_dupes = 0
1243
+ voided_runs = 0
1244
+ void_reasons = []
1245
+ void_orphans = 0
968
1246
  for repo in repos:
969
1247
  g = read_stream(repo, "gates")
970
- r = read_stream(repo, "runs")
971
- 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
972
1252
  # Per repo, not across them (SCHEMA.md §4): the OpenCode plugin
973
1253
  # appends a cumulative snapshot at every root-session idle.
974
1254
  s, sd = dedupe_sessions(read_stream(repo, "sessions"))
@@ -976,6 +1256,11 @@ def analyse(repos):
976
1256
  # Per repo, not across them: two repos may legitimately share a short sha.
977
1257
  c, d = dedupe_commits(read_stream(repo, "commits"))
978
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
979
1264
  gates += g; runs += r; sessions += s; commits += c
980
1265
  per_repo.append({"repo": repo, "app": app_name(repo),
981
1266
  "project_type": project_type(repo)[0],
@@ -996,9 +1281,19 @@ def analyse(repos):
996
1281
  "commit_hook": has_commit_hook(repo)})
997
1282
 
998
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.
999
1291
  d = defaultdict(list)
1000
1292
  for r in records:
1001
- 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")
1002
1297
  d[key].append(r)
1003
1298
  return d
1004
1299
 
@@ -1031,6 +1326,10 @@ def analyse(repos):
1031
1326
 
1032
1327
  out = {"per_repo": per_repo, "tainted_reqs": sorted("%s:%s" % x for x in tainted if x[1]),
1033
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,
1034
1333
  "misses": analyse_misses(misses),
1035
1334
  "phases": analyse_phases(runs)}
1036
1335
 
@@ -1069,10 +1368,12 @@ def analyse(repos):
1069
1368
  # ---- poolable metrics: runs, cadence, tokens. Exempt from both separations.
1070
1369
  build_runs = [r for r in runs if r.get("cmd") == "build-phase"]
1071
1370
  fix_runs = [r for r in runs if r.get("mode") == "fix"]
1072
- throughput = [float(r["reqs_count"]) / r["duration_s"]
1073
- 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")]
1074
1373
  batch = [r["reqs_count"] for r in build_runs if r.get("reqs_count") is not None]
1075
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]
1076
1377
  tok = sum((s.get("input_tokens") or 0) + (s.get("output_tokens") or 0) for s in sessions)
1077
1378
  days = {c.get("ts", "")[:10] for c in commits if c.get("ts")}
1078
1379
  out["pooled"] = {
@@ -1086,7 +1387,16 @@ def analyse(repos):
1086
1387
  "tokens_total": tok,
1087
1388
  "tokens_per_verified_req": round(float(tok) / verified_transitions, 1)
1088
1389
  if verified_transitions >= MIN_N and tok else None,
1089
- "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),
1090
1400
  "commits": len(commits),
1091
1401
  "commit_duplicates_collapsed": commit_dupes,
1092
1402
  "session_duplicates_collapsed": session_dupes,
@@ -1173,6 +1483,20 @@ def print_report(a, repos):
1173
1483
  print(" across project_type or across live/backfilled would produce a")
1174
1484
  print(" number that cannot be defended. See SCHEMA.md §6.")
1175
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("")
1176
1500
  if a["tainted_reqs"]:
1177
1501
  print(" %d REQ(s) excluded from the LIVE first-pass rate because they carry"
1178
1502
  % len(a["tainted_reqs"]))
@@ -1193,8 +1517,14 @@ def print_report(a, repos):
1193
1517
  print(" REQ throughput : %s REQs/hour (median across runs)" %
1194
1518
  (p["throughput_median_reqs_per_hour"] if p["throughput_median_reqs_per_hour"] is not None else "insufficient data"))
1195
1519
  print(" sessions / tokens : %d sessions, %s total tokens" % (p["sessions"], "{:,}".format(p["tokens_total"])))
1196
- 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)" %
1197
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"])
1198
1528
  print(" commit cadence : %s commits/active day (%d commits over %d days)" %
1199
1529
  (p["commits_per_active_day"] if p["commits_per_active_day"] is not None else "—",
1200
1530
  p["commits"], p["active_days"]))
@@ -1243,6 +1573,15 @@ def print_phases(p, W):
1243
1573
  print("-" * W)
1244
1574
  print(" %d live run record(s). Token-window coverage: %s"
1245
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 ""))
1246
1585
  print(" A window is only as good as its scope: 'tree' saw the subagents, 'main'")
1247
1586
  print(" did not look, 'none'/absent measured nothing at all and is excluded from")
1248
1587
  print(" every token figure below rather than averaged in as a zero.")
@@ -1323,8 +1662,57 @@ def print_phases(p, W):
1323
1662
  for h, c in m["cost_usd_by_harness"].items():
1324
1663
  print(" cost (%s) : $%s over %d record(s) — MEASURED, never pooled "
1325
1664
  "across harness" % (h, c["usd"], c["records"]))
1326
- if m["reqs_touched_total"] or m["files_written_total"]:
1327
- bm = m.get("by_mode") or {}
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 {}
1328
1716
  if len(bm) > 1:
1329
1717
  print(" by mode : (in the order they ran)")
1330
1718
  for mode, v in bm.items():
@@ -1453,6 +1841,15 @@ def print_misses(m, W):
1453
1841
  print(" Claude Code carries cost_usd:null permanently and is NEVER")
1454
1842
  print(" priced from a rate card here (SCHEMA.md §4). Real dollars come from")
1455
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).")
1456
1853
  print(" shared (apportioned): %d fix records — equal division, NOT a measurement"
1457
1854
  % m["cost_shared_n"])
1458
1855
  print(" tokens out per miss : %s (n=%d priced)" %