@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
@@ -19,6 +19,12 @@
19
19
  # tf-emit.sh --amend MISS-App-20260828-01 why_missed missing-checklist-item
20
20
  # # fills a field that is still null on a miss already on
21
21
  # # the stream (SCHEMA.md §5.5.7). Never overwrites.
22
+ # tf-emit.sh --void-run build-phase 2026-09-09T11:40:00Z "the start time was guessed, not measured"
23
+ # # marks a WRONG run record as not to be counted: both
24
+ # # records stay, every figure skips it and says how many
25
+ # # and why (SCHEMA.md §2.7). Refuses if no such run.
26
+ # tf-emit.sh --origin-of REQ-UI-014 # prints "<started> <cmd> <agent>" of the last build or fix
27
+ # # run that touched the row, nothing when there is none
22
28
  # tf-emit.sh --where # prints the resolved docs/metrics dir
23
29
  #
24
30
  # STREAMS: runs | gates | sessions | commits | misses (anything else is dropped)
@@ -131,6 +137,39 @@ PY
131
137
  exit 0
132
138
  fi
133
139
 
140
+ # --- read helper: which run caused a row's defect ------------------------
141
+ # Prints "<started> <cmd> <agent>" for the most recent non-backfilled build-phase
142
+ # or fix-issues run whose reqs_touched holds the REQ, nothing when there is none
143
+ # (the caller then leaves origin_run_id out and the record is marked inferred).
144
+ # Used by tf-log-miss.sh and tf-triage.sh (Sitting 4c, 2026-09-06).
145
+ if [[ "$1" == "--origin-of" ]]; then
146
+ REQ="$2"
147
+ [[ -n "$REQ" ]] || exit 0
148
+ python3 - "$MET_DIR/runs.jsonl" "$REQ" 2>"$TF_ERR" <<'PY2' || true
149
+ import json, sys
150
+ path, req = sys.argv[1], sys.argv[2].upper()
151
+ best = None
152
+ try:
153
+ with open(path, "r", encoding="utf-8") as fh:
154
+ for line in fh:
155
+ try:
156
+ r = json.loads(line)
157
+ except Exception:
158
+ continue
159
+ if r.get("kind") != "run" or r.get("backfilled") or r.get("cmd") not in ("build-phase", "fix-issues"):
160
+ continue
161
+ if req in [str(x).upper() for x in (r.get("reqs_touched") or [])]:
162
+ if best is None or r.get("started", "") >= best.get("started", ""):
163
+ best = r
164
+ except FileNotFoundError:
165
+ pass
166
+ if best:
167
+ subs = best.get("subagents") or []
168
+ print(best.get("started", ""), best.get("cmd"), subs[0] if subs else "flow-master")
169
+ PY2
170
+ exit 0
171
+ fi
172
+
134
173
  # --- read helper: next RUN attempt number for a cmd + REQ set ------------
135
174
  # run attempt = 1 + count of prior NON-BACKFILLED `run` records with the same
136
175
  # cmd whose reqs_touched intersects the given REQ IDs. Same counting rule that
@@ -170,9 +209,10 @@ fi
170
209
  # `miss-fix`, and a collision would silently merge two defects into one lifecycle.
171
210
  if [[ "$1" == "--next-miss-id" ]]; then
172
211
  python3 - "$MET_DIR/misses.jsonl" "$ROOT" 2>"$TF_ERR" <<'PY' || true
173
- import datetime, glob, json, os, sys
212
+ import datetime, glob, json, os, re, sys
174
213
  path, root = sys.argv[1], sys.argv[2]
175
- hits = glob.glob(os.path.join(root, "docs", "*-Checklist.md"))
214
+ hits = [h for h in glob.glob(os.path.join(root, "docs", "*-Checklist.md"))
215
+ if not re.search(r"-(Deployment|P\d+)-Checklist\.md$", h)] # only the phase-1 work list names the app
176
216
  app = (os.path.basename(hits[0])[: -len("-Checklist.md")] if len(hits) == 1
177
217
  else os.path.basename(os.path.abspath(root)))
178
218
  day = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d")
@@ -234,6 +274,61 @@ fi
234
274
  # patience rather than quality. Open = no miss-fix, or the latest miss-fix
235
275
  # closed with a verdict_after other than "Verified". Prints "<miss_id>
236
276
  # <miss_class>" for the most recent open miss, or nothing at all.
277
+ # --- list the open misses whose deficient artifact is a document ----------
278
+ # `*amend-docs` is the command that edits a BRD, an Architecture or a mockup, so it is
279
+ # the only command that can close a miss whose artifact is one of those -- and it had no
280
+ # way to find them. Every other close path works off a checklist row the verifier touched,
281
+ # which a BRD edit never is, so a document miss stayed open forever (TF-016: twelve
282
+ # finished TfLens items showed as outstanding for up to two weeks).
283
+ #
284
+ # tf-emit.sh --open-misses <App> [--artifact-class doc]
285
+ #
286
+ # Prints one line per open miss: "<miss_id> <artifact> <what>". Open means no miss-fix
287
+ # carries verdict_after Verified, the same test --open-miss applies.
288
+ if [[ "$1" == "--open-misses" ]]; then
289
+ OMAPP="${2:-}"; OMCLASS=""
290
+ [[ "${3:-}" == "--artifact-class" ]] && OMCLASS="${4:-}"
291
+ TF_OMAPP="$OMAPP" TF_OMCLASS="$OMCLASS" python3 - "$MET_DIR/misses.jsonl" 2>"$TF_ERR" <<'PY2' || true
292
+ import json, os, sys
293
+ DOC = {"brd", "architecture", "uidesign", "checklist", "devguide"}
294
+ app, klass = os.environ.get("TF_OMAPP") or "", os.environ.get("TF_OMCLASS") or ""
295
+ opened, fixes = [], {}
296
+ try:
297
+ with open(sys.argv[1], encoding="utf-8") as fh:
298
+ for line in fh:
299
+ line = line.strip()
300
+ if not line:
301
+ continue
302
+ try:
303
+ r = json.loads(line)
304
+ except Exception:
305
+ continue
306
+ if r.get("kind") == "miss":
307
+ opened.append(r)
308
+ elif r.get("kind") == "miss-fix":
309
+ mid = r.get("miss_id")
310
+ if mid:
311
+ prev = fixes.get(mid)
312
+ if prev is None or (r.get("ts") or "") >= (prev.get("ts") or ""):
313
+ fixes[mid] = r
314
+ except FileNotFoundError:
315
+ pass
316
+ for r in opened:
317
+ if app and r.get("app") and r.get("app") != app:
318
+ continue
319
+ art = r.get("artifact") or ""
320
+ if klass == "doc" and art not in DOC:
321
+ continue
322
+ if klass and klass != "doc" and art != klass:
323
+ continue
324
+ fix = fixes.get(r.get("miss_id"))
325
+ if fix is not None and fix.get("verdict_after") == "Verified":
326
+ continue
327
+ print("%s %s %s" % (r.get("miss_id"), art or "-", (r.get("what") or "")[:110]))
328
+ PY2
329
+ exit 0
330
+ fi
331
+
237
332
  if [[ "$1" == "--open-miss" ]]; then
238
333
  REQ="$2"
239
334
  [[ -n "$REQ" ]] || exit 0
@@ -304,10 +399,23 @@ AMENDABLE = {
304
399
  "why_missed": ("missing-checklist-item", "insufficient-verify-method",
305
400
  "code-audit-limitation", "ambiguous-acceptance",
306
401
  "dependency-not-declared", "instruction-ignored", "other"),
402
+ # whose gap it was, the four-question sort (FR-32, Session 5): a judgement a reader can
403
+ # still make later, so a miss logged before the field existed can be sorted now.
404
+ "sort": ("spec", "unsaid", "weak-check", "ignored"),
307
405
  }
308
- if fld not in AMENDABLE:
406
+ # `what` is the miss's own sentence, and it is FREE TEXT -- the one field here that
407
+ # is not a closed vocabulary. It is allowed because the protection was never the
408
+ # vocabulary: an amend may only fill a field that is still null and can never
409
+ # overwrite one that is set (below), so free text cannot rewrite a fact, only
410
+ # supply one that was missing. Validated as non-empty text instead.
411
+ # MISS-TechieFlow-20260909-02; tests/regression/run.sh tf_018.
412
+ AMENDABLE_TEXT = ("what",)
413
+ if fld not in AMENDABLE and fld not in AMENDABLE_TEXT:
309
414
  print("REFUSED %s is not an amendable field (SCHEMA.md §5.5.7)" % fld); raise SystemExit(0)
310
- if val not in AMENDABLE[fld]:
415
+ if fld in AMENDABLE_TEXT:
416
+ if not isinstance(val, str) or not val.strip():
417
+ print("REFUSED %s must be a non-empty sentence" % fld); raise SystemExit(0)
418
+ elif val not in AMENDABLE[fld]:
311
419
  print("REFUSED %r is not in the closed vocabulary for %s" % (val, fld)); raise SystemExit(0)
312
420
 
313
421
  parent, current = None, None
@@ -350,8 +458,77 @@ PY
350
458
  exit 0
351
459
  fi
352
460
 
461
+ # --- write helper: mark a run record as not to be counted -----------------
462
+ # SCHEMA.md §2.7. The same doctrine as --amend, one stream over: a run record that
463
+ # is WRONG cannot be edited or deleted, because the stream is append-only and the
464
+ # evidence that it happened is worth keeping. So the correction is another record.
465
+ #
466
+ # tf-emit.sh --void-run <cmd> <started> "<why it is wrong>"
467
+ #
468
+ # It names the run by its own `cmd` and `started` — the pair every run record
469
+ # carries and the key the readers already use — and every figure then skips it,
470
+ # with the count and the reasons printed. A void that names no run on this stream
471
+ # is refused: it would be a claim about nothing. It is deliberately NOT a delete:
472
+ # both records stay, and a reader can see what was corrected and why.
473
+ if [[ "$1" == "--void-run" ]]; then
474
+ VCMD="$2"; VSTART="$3"; VWHY="$4"
475
+ if [[ -z "$VCMD" || -z "$VSTART" || -z "$VWHY" ]]; then
476
+ echo "tf-emit: --void-run needs <cmd> <started> \"<reason>\""
477
+ exit 0
478
+ fi
479
+ VREC="$(python3 - "$MET_DIR/runs.jsonl" "$VCMD" "$VSTART" "$VWHY" 2>"$TF_ERR" <<'PY' || true
480
+ import json, sys
481
+ path, cmd, started, why = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
482
+ target, voided, app = None, False, None
483
+ try:
484
+ with open(path, encoding="utf-8") as fh:
485
+ for line in fh:
486
+ line = line.strip()
487
+ if not line:
488
+ continue
489
+ try:
490
+ r = json.loads(line)
491
+ except Exception:
492
+ continue
493
+ if r.get("cmd") == cmd and r.get("started") == started:
494
+ if r.get("kind") == "run-void":
495
+ voided = True
496
+ elif r.get("kind", "run") == "run":
497
+ target = r
498
+ app = r.get("app")
499
+ except FileNotFoundError:
500
+ pass
501
+
502
+ if target is None:
503
+ print("REFUSED no run record %s started %s on this stream" % (cmd, started)); raise SystemExit(0)
504
+ if voided:
505
+ print("REFUSED %s started %s is already voided — one void is enough" % (cmd, started)); raise SystemExit(0)
506
+ if not why.strip():
507
+ print("REFUSED a void needs a reason a reader can check"); raise SystemExit(0)
508
+
509
+ print("OK " + json.dumps({"kind": "run-void", "app": app, "cmd": cmd,
510
+ "started": started, "reason": why.strip()},
511
+ separators=(",", ":"), ensure_ascii=False))
512
+ PY
513
+ )"
514
+ case "$VREC" in
515
+ "OK "*) printf '%s' "${VREC#OK }" | bash "${BASH_SOURCE[0]}" runs
516
+ echo "tf-emit: voided $VCMD started $VSTART — $VWHY"
517
+ echo " both records stay on the stream; every figure now skips the run and says so" ;;
518
+ "REFUSED "*) echo "tf-emit: void refused — ${VREC#REFUSED }" ;;
519
+ *) echo "tf-emit: void could not be evaluated — nothing written" ;;
520
+ esac
521
+ exit 0
522
+ fi
523
+
353
524
  # --- append path ----------------------------------------------------------
354
525
  STREAM="$1"
526
+ # --allow-overlap: this run really did happen while another was still open (a second machine
527
+ # appending to the same merge=union stream is the only case that has ever come up). Without it a
528
+ # run whose `started` precedes the previous run's `ended` is refused — see _overlap_ok below.
529
+ TF_ALLOW_OVERLAP=0
530
+ for _a in "$@"; do [[ "$_a" == "--allow-overlap" ]] && TF_ALLOW_OVERLAP=1; done
531
+ export TF_ALLOW_OVERLAP
355
532
  case "$STREAM" in
356
533
  runs|gates|sessions|commits|misses) ;;
357
534
  *) _warn "unknown stream '${STREAM:-<none>}' — event dropped"; exit 0 ;;
@@ -360,7 +537,7 @@ esac
360
537
  # The program is captured into a variable rather than fed on stdin — a heredoc
361
538
  # on `python3 -` would REPLACE the caller's piped JSON and silently drop every event.
362
539
  TF_PROG="$(cat <<'PY'
363
- import json, os, sys, datetime, glob, re
540
+ import json, os, sys, datetime, glob, re, subprocess, time
364
541
 
365
542
  met_dir, stream, root = sys.argv[1], sys.argv[2], sys.argv[3]
366
543
  dbg = os.environ.get("TF_METRICS_DEBUG")
@@ -456,11 +633,8 @@ def _detect_harness():
456
633
  # the OTHER harness inherits that harness's markers (e.g. OpenCode started
457
634
  # from a Claude Code bash still carries CLAUDE_PROJECT_DIR).
458
635
  tf = os.environ.get("TF_HARNESS")
459
- if tf in ("claude-code", "opencode", "codex"):
636
+ if tf in ("claude-code", "opencode"):
460
637
  return tf
461
- for k in ("CODEX_THREAD_ID", "CODEX_SESSION_ID"):
462
- if os.environ.get(k):
463
- return "codex"
464
638
  for k in ("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION_ID",
465
639
  "CLAUDE_PROJECT_DIR"):
466
640
  if os.environ.get(k):
@@ -488,8 +662,6 @@ def _detect_harness():
488
662
  name, ppid = os.path.basename(parts[0]), int(parts[1])
489
663
  if name and "opencode" in name.lower():
490
664
  return "opencode"
491
- if name and "codex" in name.lower():
492
- return "codex"
493
665
  if name and "claude" in name.lower():
494
666
  return "claude-code"
495
667
  pid = ppid
@@ -505,7 +677,11 @@ except Exception:
505
677
  # app name — inferred from the one checklist, else the repo directory name
506
678
  def _app_name():
507
679
  try:
508
- hits = glob.glob(os.path.join(root, "docs", "*-Checklist.md"))
680
+ # The Deployment Checklist and a Large project's phase-n checklists share the suffix;
681
+ # only <App>-Checklist.md names the app, else a copy such as TfLens-oc was recorded
682
+ # under its folder name (MISS-TechieFlow-20260906-04).
683
+ hits = [h for h in glob.glob(os.path.join(root, "docs", "*-Checklist.md"))
684
+ if not re.search(r"-(Deployment|P\d+)-Checklist\.md$", h)]
509
685
  if len(hits) == 1:
510
686
  return os.path.basename(hits[0])[: -len("-Checklist.md")]
511
687
  except Exception:
@@ -524,6 +700,80 @@ def enrich(rec):
524
700
  DECISIONS.md 2026-08-20)."""
525
701
  rec.setdefault("v", 1)
526
702
  rec.setdefault("ts", NOW)
703
+ # A run record says whether YOLO was on (D-12, FR-35; Sitting 4c, 2026-09-06): the task
704
+ # never has to remember it. TF_YOLO=1 (the supervisor) or a live .tfcore/.session/yolo.json.
705
+ if rec.get("kind") == "run" and "yolo" not in rec:
706
+ on = os.environ.get("TF_YOLO") == "1"
707
+ if not on:
708
+ try:
709
+ fl = os.path.join(root, ".tfcore", ".session", "yolo.json")
710
+ on = os.path.isfile(fl) and (time.time() - os.path.getmtime(fl)) < 24 * 3600
711
+ except Exception:
712
+ on = False
713
+ rec["yolo"] = bool(on)
714
+ # A run record without `started` takes it from the command marker written by
715
+ # tf-phase.sh start (step 0 of every task), when the marker names the same command.
716
+ if rec.get("kind") == "run" and not rec.get("started"):
717
+ try:
718
+ with open(os.path.join(root, ".tfcore", ".session", "phase.json"), encoding="utf-8") as fh:
719
+ mk = json.load(fh) or {}
720
+ if mk.get("started") and (not rec.get("cmd") or mk.get("cmd") == rec.get("cmd")):
721
+ rec["started"] = mk["started"]
722
+ rec.setdefault("ended", NOW)
723
+ sys.stdout.write("tf-emit: started taken from the command marker (%s)\n" % mk["started"])
724
+ except Exception:
725
+ pass
726
+ # A run's `ended` is when the record is written, never a guess: an ended in the
727
+ # future (more than a minute past now) or before started is replaced with now and
728
+ # duration_s recomputed (MISS-TechieFlow-20260905-11: a day-1 run wrote 17:05 at 16:40).
729
+ # The same rule the other way round: a run record with NO `ended` was silently
730
+ # accepted and could never be costed — the effort-per-phase figure just lost it
731
+ # (MISS-TechieFlow-20260907-09, the session-6 record itself). `ended` is when the
732
+ # record is written, so an absent one is filled in, never left empty.
733
+ if rec.get("kind") == "run" and not isinstance(rec.get("ended"), str):
734
+ rec["ended"] = NOW
735
+ if rec.get("kind") == "run" and isinstance(rec.get("ended"), str):
736
+ t_end, t_now = _iso_ms(rec["ended"]), _iso_ms(NOW)
737
+ t_start = _iso_ms(rec["started"]) if isinstance(rec.get("started"), str) else None
738
+ # Two different faults, and they must not share a remedy.
739
+ #
740
+ # (a) `ended` a little in the future: `started` is still trustworthy, so clamp to
741
+ # now and re-derive (MISS-TechieFlow-20260905-11, a day-1 run wrote 17:05 at 16:40).
742
+ # (b) `ended` BEFORE `started`: the record contradicts itself and there is no way to
743
+ # tell which of the two is wrong, so it carries NO duration at all. Deriving one
744
+ # from the surviving timestamp invents a figure -- a TechieBlog record started
745
+ # 2026-08-22 would have been "measured" at 1,515,038 seconds on the day it was
746
+ # re-emitted. An honest gap beats a plausible number: SCHEMA §2.5.
747
+ if t_end is not None and t_now is not None and t_start is not None and t_end < t_start:
748
+ sys.stdout.write("tf-emit: ended %s precedes started %s; the record contradicts "
749
+ "itself, so no duration is stored\n" % (rec["ended"], rec["started"]))
750
+ rec["ended"] = NOW
751
+ rec.pop("duration_s", None)
752
+ rec["duration_unmeasured"] = "ended-before-started"
753
+ elif t_end is not None and t_now is not None and t_end > t_now + 60_000:
754
+ sys.stdout.write("tf-emit: ended %s is not a measurement (it lies in the future); "
755
+ "set to now, %s\n" % (rec["ended"], NOW))
756
+ rec["ended"] = NOW
757
+ if t_start is not None and "duration_s" in rec:
758
+ rec["duration_s"] = max(0, (t_now - t_start) // 1000)
759
+ elif t_end is not None and t_start is not None and isinstance(rec.get("duration_s"), (int, float)):
760
+ # A supplied duration that bears no relation to its own timestamps. Thirteen of
761
+ # TechieBlog's fourteen impossible records hid here, storing a plausible round
762
+ # number (3600, 2700, 1800, 900, 600) that nothing downstream could question --
763
+ # only the ONE record that stored the negative arithmetic was ever detectable.
764
+ span = (t_end - t_start) // 1000
765
+ if abs(span - rec["duration_s"]) > 1:
766
+ sys.stdout.write("tf-emit: duration_s %s disagrees with started/ended (%ss); "
767
+ "using the timestamps\n" % (rec["duration_s"], span))
768
+ rec["duration_s"] = span
769
+ # A run record that omits duration_s gets it from started and ended (the devguide and
770
+ # refresh-status records of 2026-09-06 had none; MISS-TechieFlow-20260906-10).
771
+ if rec.get("kind") == "run" and "duration_s" not in rec and rec.get("started") and rec.get("ended") \
772
+ and not rec.get("duration_unmeasured"):
773
+ try:
774
+ rec["duration_s"] = max(0, (_iso_ms(rec["ended"]) - _iso_ms(rec["started"])) // 1000)
775
+ except Exception:
776
+ pass
527
777
  if "project_type" not in rec:
528
778
  if PTYPE:
529
779
  rec["project_type"] = PTYPE
@@ -531,7 +781,10 @@ def enrich(rec):
531
781
  # Default, but NEVER silently — the report labels these unclassified.
532
782
  rec["project_type"] = "app"
533
783
  rec["project_type_inferred"] = True
534
- rec.setdefault("harness", HARNESS)
784
+ if HARNESS:
785
+ rec["harness"] = HARNESS # detected, never declared (SCHEMA.md §1)
786
+ else:
787
+ rec.setdefault("harness", None)
535
788
  rec.setdefault("app", APP)
536
789
  return rec
537
790
 
@@ -574,6 +827,52 @@ def _routing():
574
827
 
575
828
  ROUTING = _routing()
576
829
 
830
+ # --- how a model is paid for (SCHEMA.md §2.5b, added 2026-09-10) -----------
831
+ # One cost column cannot mean three things at once. A run on a flat-fee subscription records
832
+ # zero marginal dollars, a run on a metered API key records real money, and a run on a local
833
+ # model records zero because there is no bill — and until this field existed a reader could
834
+ # not tell those three zeroes apart, so every "what did it cost" figure quietly averaged real
835
+ # spending with free runs. The answer is resolved by tf-model-pick.sh, which reads OpenCode's
836
+ # own auth.json and model catalog, so it is looked up and never declared by an agent — the
837
+ # same rule as `harness` (§1) and `origin_model` (§5.5.1).
838
+ _BILLING_CACHE = {}
839
+
840
+
841
+ def _billing(model, harness):
842
+ key = (model, harness)
843
+ if key not in _BILLING_CACHE:
844
+ mode, source = "unknown", "none"
845
+ pick = os.path.join(root, ".tfcore", "utils", "tf-model-pick.sh")
846
+ if model and os.path.isfile(pick):
847
+ try:
848
+ out = subprocess.run(["bash", pick, "billing", model, harness or "claude"],
849
+ capture_output=True, text=True, timeout=20,
850
+ env=dict(os.environ, TF_PROJECT_DIR=root))
851
+ parts = (out.stdout or "").strip().split("\t")
852
+ if parts and parts[0] in ("subscription", "plan", "metered", "local", "unknown"):
853
+ mode = parts[0]
854
+ source = parts[1] if len(parts) > 1 else "tf-model-pick"
855
+ except Exception:
856
+ pass
857
+ _BILLING_CACHE[key] = (mode, source)
858
+ return _BILLING_CACHE[key]
859
+
860
+
861
+ def _was_limited(model, harness, at_epoch):
862
+ """Did this run start while the tier's own model was parked? Then it is a deliberate
863
+ fallback, not routing drift, and `routed:false` should not be read as one."""
864
+ pick = os.path.join(root, ".tfcore", "utils", "tf-model-pick.sh")
865
+ if not model or not os.path.isfile(pick) or at_epoch is None:
866
+ return False
867
+ try:
868
+ out = subprocess.run(["bash", pick, "was-limited", harness or "claude", model, str(int(at_epoch))],
869
+ capture_output=True, text=True, timeout=20,
870
+ env=dict(os.environ, TF_PROJECT_DIR=root))
871
+ return out.returncode == 0
872
+ except Exception:
873
+ return False
874
+
875
+
577
876
  def _iso_ms(s):
578
877
  try:
579
878
  return int(datetime.datetime.strptime(
@@ -747,6 +1046,97 @@ def _prior_runs():
747
1046
  pass
748
1047
  return _PRIOR_RUNS
749
1048
 
1049
+ # --- a run may not start before the last one finished (FR-72, 2026-09-10) --
1050
+ # `--void-run` (§2.7) gave a way to CORRECT a run record written with a start time nobody
1051
+ # measured. Nothing refused one, so the same mistake happened twice more the day the correction
1052
+ # mechanism shipped — two records whose `started` preceded the previous record's `ended`, one by
1053
+ # 42 minutes and one by nearly three hours, each silently adding that overlap to every total
1054
+ # (MISS-TechieFlow-20260910-04, sorted `ignored`: it was written down and not followed).
1055
+ #
1056
+ # A stream is append-only, so the only place to stop it is before the write. The rule: for one
1057
+ # app, a live `run` record may not begin before the last live `run` record ended. Voided and
1058
+ # backfilled records are ignored, which is what makes the void-then-re-emit flow work. Records
1059
+ # with no `started` are untouched.
1060
+ #
1061
+ # The one legitimate overlap is two machines appending to the same merge=union stream, so
1062
+ # `--allow-overlap` exists and the refusal message names it. It is deliberately a flag and not a
1063
+ # fallback: the mistake this refuses is easy to make and invisible afterwards.
1064
+ _LAST_END = None
1065
+
1066
+
1067
+ def _last_live_end(app):
1068
+ global _LAST_END
1069
+ if _LAST_END is None:
1070
+ _LAST_END = {}
1071
+ voided = set()
1072
+ rows = []
1073
+ try:
1074
+ with open(os.path.join(met_dir, "runs.jsonl"), encoding="utf-8") as fh:
1075
+ for line in fh:
1076
+ line = line.strip()
1077
+ if not line:
1078
+ continue
1079
+ try:
1080
+ r = json.loads(line)
1081
+ except Exception:
1082
+ continue
1083
+ if r.get("kind") == "run-void":
1084
+ voided.add((r.get("app"), r.get("cmd"), r.get("started")))
1085
+ elif r.get("kind", "run") == "run" and not r.get("backfilled"):
1086
+ rows.append(r)
1087
+ except Exception:
1088
+ pass
1089
+ for r in rows:
1090
+ if (r.get("app"), r.get("cmd"), r.get("started")) in voided:
1091
+ continue
1092
+ # `ended` ONLY. `ts` is when the record was WRITTEN, not when the run finished, so
1093
+ # falling back to it would make every historical record emitted today look like an
1094
+ # overlap with the record written a second ago. A run with no `ended` has no window
1095
+ # and constrains nothing.
1096
+ #
1097
+ # And only a record whose window is COHERENT: a self-contradicting one (ended before
1098
+ # started) has its `ended` replaced with the write time by the check above, and
1099
+ # marked by a null `duration_s`. That placeholder is not a measurement, so it must
1100
+ # not become a boundary — it would block every later record for the same app.
1101
+ if not isinstance(r.get("duration_s"), (int, float)):
1102
+ continue
1103
+ end = r.get("ended")
1104
+ if not end:
1105
+ continue
1106
+ a = r.get("app")
1107
+ if a not in _LAST_END or end > _LAST_END[a][0]:
1108
+ _LAST_END[a] = (end, r.get("cmd"), r.get("started"))
1109
+ return _LAST_END.get(app)
1110
+
1111
+
1112
+ def _overlap_ok(rec):
1113
+ """False (and the reason printed) when this run would start before the last one ended."""
1114
+ if stream != "runs" or rec.get("kind", "run") != "run" or rec.get("backfilled"):
1115
+ return True
1116
+ if os.environ.get("TF_ALLOW_OVERLAP") == "1":
1117
+ return True
1118
+ started = rec.get("started")
1119
+ if not started:
1120
+ return True
1121
+ prev = _last_live_end(rec.get("app"))
1122
+ if not prev:
1123
+ return True
1124
+ a, b = _iso_ms(started), _iso_ms(prev[0])
1125
+ if a is not None and b is not None:
1126
+ if a >= b:
1127
+ return True
1128
+ elif started >= prev[0]: # unparseable timestamp: fall back to the string order
1129
+ return True
1130
+ sys.stdout.write(
1131
+ "tf-emit: REFUSED — this run starts at %s, before the previous run for %s ended at %s "
1132
+ "(%s started %s). The two would overlap and every total over them would be that much too "
1133
+ "big. Use the previous run's `ended` as this one's `started`, or pass --allow-overlap if "
1134
+ "they really did run at the same time (a second machine on the same stream). "
1135
+ "Nothing was appended.\n"
1136
+ % (started, rec.get("app"), prev[0], prev[1], prev[2]))
1137
+ return False
1138
+
1139
+
750
1140
  def stamp_attempt(rec):
751
1141
  if stream != "runs" or "attempt" in rec or rec.get("backfilled"):
752
1142
  return rec
@@ -771,7 +1161,7 @@ def enrich_run(rec):
771
1161
  tier = ROUTING["phases"].get(cmd)
772
1162
  if tier and tier != "inherit":
773
1163
  rec["tier"] = tier
774
- key = {"claude-code": "claude", "opencode": "opencode", "codex": "codex"}.get(HARNESS)
1164
+ key = {"claude-code": "claude", "opencode": "opencode"}.get(HARNESS)
775
1165
  tm = ROUTING["tiers"].get(tier, {}).get(key) if key else None
776
1166
  if tm:
777
1167
  rec["tier_model"] = tm
@@ -812,8 +1202,21 @@ def enrich_run(rec):
812
1202
  rec["tokens_out_subagents"] = win.get("sub_out", 0)
813
1203
  if rec.get("tier_model") and rec.get("model"):
814
1204
  rec["routed"] = rec["model"] == rec["tier_model"]
1205
+ # What a cost figure from this window MEANS. Several models in one window may
1206
+ # be paid for differently — a fallback from a subscription to a metered key is
1207
+ # exactly that case — so a mixed window says so rather than picking a winner.
1208
+ modes = {_billing(m, HARNESS)[0] for m in models} or {_billing(rec.get("model"), HARNESS)[0]}
1209
+ rec["billing_mode"] = modes.pop() if len(modes) == 1 else "mixed"
1210
+ rec["cost_source"] = "opencode-db" if HARNESS == "opencode" else "none"
1211
+ # `routed:false` because the tier model was out of quota is a different fact from
1212
+ # `routed:false` because something drifted. Only the first names a model here.
1213
+ tm = rec.get("tier_model")
1214
+ if tm and rec.get("model") and rec["model"] != tm and _was_limited(tm, HARNESS, t0 / 1000.0):
1215
+ rec["fallback_from"] = tm
815
1216
  else:
816
1217
  rec["tokens_scope"] = "none"
1218
+ rec["billing_mode"] = _billing(rec.get("tier_model"), HARNESS)[0]
1219
+ rec["cost_source"] = "none"
817
1220
  except Exception as e:
818
1221
  warn("run enrichment failed (%s) — record kept unenriched" % e)
819
1222
  return rec
@@ -881,7 +1284,7 @@ def _runs_by_start():
881
1284
  return _RUNS_BY_START
882
1285
 
883
1286
  _COST_FIELDS = ("tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write",
884
- "cost_usd", "tokens_scope", "model")
1287
+ "cost_usd", "tokens_scope", "model", "billing_mode", "cost_source")
885
1288
 
886
1289
  # Kept identical to the --amend branch above. Two doors, ONE enforcement: an
887
1290
  # agent that hand-writes a miss-amend record onto the stream faces the same
@@ -891,14 +1294,23 @@ _AMENDABLE = {
891
1294
  "why_missed": ("missing-checklist-item", "insufficient-verify-method",
892
1295
  "code-audit-limitation", "ambiguous-acceptance",
893
1296
  "dependency-not-declared", "instruction-ignored", "other"),
1297
+ "sort": ("spec", "unsaid", "weak-check", "ignored"),
894
1298
  }
1299
+ _AMENDABLE_TEXT = ("what",) # free text, guarded by the never-overwrite rule (see --amend)
895
1300
 
896
1301
  def _amend_ok(rec):
897
1302
  """True when this miss-amend may be appended. Returning False DROPS it —
898
1303
  an invalid amend writes nothing rather than landing as a record the reader
899
1304
  then has to defend itself against."""
900
1305
  mid, fld, val = rec.get("miss_id"), rec.get("field"), rec.get("value")
901
- if fld not in _AMENDABLE or val not in _AMENDABLE[fld] or not mid:
1306
+ if not mid:
1307
+ warn("amend refused — no miss_id")
1308
+ return False
1309
+ if fld in _AMENDABLE_TEXT:
1310
+ if not isinstance(val, str) or not val.strip():
1311
+ warn("amend refused — %s must be a non-empty sentence" % fld)
1312
+ return False
1313
+ elif fld not in _AMENDABLE or val not in _AMENDABLE[fld]:
902
1314
  warn("amend refused — %r/%r outside the §5.5.7 allowlist" % (fld, val))
903
1315
  return False
904
1316
  parent, current = None, None
@@ -952,6 +1364,14 @@ _MISS_ENUMS = {
952
1364
  "why_missed": ("missing-checklist-item", "insufficient-verify-method",
953
1365
  "code-audit-limitation", "ambiguous-acceptance",
954
1366
  "dependency-not-declared", "instruction-ignored", "other"),
1367
+ # whose gap (FR-32, Session 5 2026-09-07): spec = the app's spec did not say it (fix the
1368
+ # checklist line) · unsaid = the framework never said it (a requirement line plus a check) ·
1369
+ # weak-check = a check existed and did not catch it (fix the check) · ignored = it was written
1370
+ # and not followed (a hook, or delete the rule)
1371
+ "sort": ("spec", "unsaid", "weak-check", "ignored"),
1372
+ }
1373
+ _REVIEW_ENUMS = {
1374
+ "phase": ("day1-review", "build-review", "verify-review", "handoff-review"),
955
1375
  }
956
1376
  _FIX_ENUMS = {
957
1377
  "verdict_after": ("Verified", "Needs re-verify", "FAIL", "deferred", "wont-fix"),
@@ -975,6 +1395,81 @@ def _enums_ok(rec, table):
975
1395
  return True
976
1396
 
977
1397
 
1398
+ # --- shape check (added 2026-09-05, reset Sitting 4a) ------------------------
1399
+ # A record may carry only the fields SCHEMA.md defines for its kind (plus the
1400
+ # ones this script injects). A `miss` must carry a miss_id from --next-miss-id.
1401
+ # Anything else is REFUSED on stdout and never appended, the same choice as
1402
+ # _enums_ok(): a field nobody defined is a field no report can read.
1403
+ _COMMON = {"v", "ts", "kind", "app", "project_type", "project_type_inferred", "harness", "backfilled"}
1404
+ _ALLOWED = {
1405
+ ("runs", "run"): {"cmd", "mode", "yolo", "started", "ended", "duration_s", "reqs_touched", "reqs_count",
1406
+ "subagents", "files_written", "build_result", "tier", "tier_model", "model", "models",
1407
+ "routed", "tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write",
1408
+ "cost_usd", "tokens_scope", "attempt", "subagent_runs", "tokens_out_subagents",
1409
+ "model_tokens_out", "session_id", "usage_start_event_ts", "usage_end_event_ts",
1410
+ "billing_mode", "cost_source", "fallback_from"},
1411
+ # A correction, not a deletion: it names a run record that is wrong by the pair every
1412
+ # run carries (`cmd` + `started`) and says why, so every figure can skip it while the
1413
+ # record itself stays where it is. Written by --void-run, which refuses one that names
1414
+ # no run. SCHEMA.md §2.7; tests/regression/run.sh tf_void.
1415
+ ("runs", "run-void"): {"cmd", "started", "reason"},
1416
+ ("gates", "gate"): {"run_id", "req_id", "req_class", "attempt", "verdict", "gate", "gates_run",
1417
+ "failure_class", "prior_verdict", "proof", "escaped", "phase", "result", "detail", "at",
1418
+ "tier", "tier_model", "model", "models", "tokens_in", "tokens_out", "tokens_cache_read",
1419
+ "tokens_cache_write", "cost_usd", "tokens_scope", "model_tokens_out", "routed",
1420
+ "subagent_runs", "tokens_out_subagents", "session_id",
1421
+ "billing_mode", "cost_source", "fallback_from"},
1422
+ ("sessions", "session"): {"session_id", "model", "duration_s", "input_tokens", "output_tokens",
1423
+ "cache_read_tokens", "cache_creation_tokens", "cost_usd", "children_sessions"},
1424
+ ("commits", "commit"): {"sha", "files", "insertions", "deletions", "subject_prefix", "branch"},
1425
+ ("misses", "miss"): {"miss_id", "req_id", "req_class", "miss_class", "artifact", "severity", "origin_phase",
1426
+ "origin_agent", "origin_run_id", "origin_model", "origin_harness", "origin_confidence",
1427
+ "why_missed", "found_by", "found_phase", "found_gate", "found_run_id", "failure_class",
1428
+ "what", "sort"},
1429
+ ("misses", "miss-fix"): {"miss_id", "req_id", "fix_run_id", "fix_cmd", "fix_attempt", "verdict_after",
1430
+ "reopened", "cost_attribution", "tokens_in", "tokens_out", "tokens_cache_read",
1431
+ "tokens_cache_write", "cost_usd", "tokens_scope", "model",
1432
+ "billing_mode", "cost_source"},
1433
+ ("misses", "miss-amend"): {"miss_id", "field", "value"},
1434
+ # an owner review of a phase's output (FR-36, built 2026-09-06): how many corrections were
1435
+ # given, what producing the reviewed output cost, what applying the corrections cost. The two
1436
+ # costs are copied from the runs named by reviewed_run_id and correction_run_id (their `started`).
1437
+ ("misses", "review"): {"phase", "reviewed_run_id", "correction_run_id", "corrections", "what",
1438
+ "tokens_produce", "tokens_correct", "cost_produce_usd", "cost_correct_usd",
1439
+ "model_produce", "model_correct"},
1440
+ }
1441
+ # Fields the emitter derives for runs/gates: a caller's value is discarded.
1442
+ _DERIVED = ("harness", "tier", "tier_model", "model", "models", "model_tokens_out", "routed",
1443
+ "tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write", "cost_usd",
1444
+ "tokens_scope", "subagent_runs", "tokens_out_subagents",
1445
+ "billing_mode", "cost_source", "fallback_from")
1446
+
1447
+
1448
+ def _shape_ok(rec):
1449
+ kind = rec.get("kind")
1450
+ if not isinstance(rec, dict) or not kind:
1451
+ sys.stdout.write("tf-emit: REFUSED — record has no \"kind\" (SCHEMA.md §1). Nothing was appended.\n")
1452
+ return False
1453
+ allowed = _ALLOWED.get((stream, kind))
1454
+ if allowed is None:
1455
+ sys.stdout.write("tf-emit: REFUSED — kind %r is not a record kind of the %s stream. Nothing was appended.\n" % (kind, stream))
1456
+ return False
1457
+ unknown = sorted(set(rec) - allowed - _COMMON)
1458
+ if unknown:
1459
+ sys.stdout.write("tf-emit: REFUSED — field(s) not in SCHEMA.md for a %s record: %s. Add the field to the schema first, or drop it. Nothing was appended.\n" % (kind, ", ".join(unknown)))
1460
+ return False
1461
+ if kind == "run" and rec.get("build_result") not in (None, "pass", "fail", "not-run"):
1462
+ sys.stdout.write("tf-emit: REFUSED — build_result must be pass, fail or not-run (SCHEMA.md §2); got %r. Put the detail in the checklist Remarks or Known blockers, not here. Nothing was appended.\n" % (rec.get("build_result"),))
1463
+ return False
1464
+ if kind == "miss" and not rec.get("miss_id"):
1465
+ sys.stdout.write("tf-emit: REFUSED — a miss record needs a miss_id; get one with: bash .tfcore/utils/tf-emit.sh --next-miss-id. Nothing was appended.\n")
1466
+ return False
1467
+ if stream in ("runs", "gates") and not rec.get("backfilled"):
1468
+ for f in _DERIVED:
1469
+ rec.pop(f, None)
1470
+ return True
1471
+
1472
+
978
1473
  def enrich_miss(rec):
979
1474
  if stream != "misses":
980
1475
  return rec
@@ -984,6 +1479,21 @@ def enrich_miss(rec):
984
1479
  return None
985
1480
  if rec.get("kind") == "miss-fix" and not _enums_ok(rec, _FIX_ENUMS):
986
1481
  return None
1482
+ if rec.get("kind") == "review":
1483
+ if not _enums_ok(rec, _REVIEW_ENUMS):
1484
+ return None
1485
+ if not isinstance(rec.get("corrections"), int) or rec["corrections"] < 0:
1486
+ sys.stdout.write("tf-emit: REFUSED — a review record needs \"corrections\", a whole number of corrections the owner gave. Nothing was appended.\n")
1487
+ return None
1488
+ for f in ("tokens_produce", "tokens_correct", "cost_produce_usd", "cost_correct_usd", "model_produce", "model_correct"):
1489
+ rec.pop(f, None) # derived, never a caller's figure
1490
+ runs = _runs_by_start()
1491
+ for side, key in (("produce", "reviewed_run_id"), ("correct", "correction_run_id")):
1492
+ src = runs.get(rec.get(key)) if rec.get(key) else None
1493
+ rec["tokens_" + side] = src.get("tokens_out") if src else None
1494
+ rec["cost_" + side + "_usd"] = src.get("cost_usd") if src else None
1495
+ rec["model_" + side] = src.get("model") if src else None
1496
+ return rec
987
1497
  try:
988
1498
  kind = rec.get("kind")
989
1499
  runs = _runs_by_start()
@@ -1003,9 +1513,11 @@ def enrich_miss(rec):
1003
1513
  rec["origin_harness"] = None
1004
1514
  elif kind == "miss-fix":
1005
1515
  src = runs.get(rec.get("fix_run_id")) if rec.get("fix_run_id") else None
1516
+ for f in _COST_FIELDS:
1517
+ rec.pop(f, None) # a caller's cost figure is never kept
1006
1518
  if src:
1007
1519
  for f in _COST_FIELDS:
1008
- if f in src and f not in rec:
1520
+ if f in src:
1009
1521
  rec[f] = src[f]
1010
1522
  if "cost_attribution" not in rec:
1011
1523
  touched = src.get("reqs_touched") or []
@@ -1048,7 +1560,14 @@ def enrich_miss(rec):
1048
1560
  # .gitattributes block pins to LF. These files are LF on every platform.
1049
1561
  lines = []
1050
1562
  for rec in records:
1051
- out = enrich_miss(enrich_run(stamp_attempt(enrich(rec))))
1563
+ if not _shape_ok(rec):
1564
+ continue
1565
+ # enrich() first, because it is what fills `app` when the caller left it out — the overlap
1566
+ # rule is per app and would otherwise compare a named run against an unnamed one.
1567
+ base = enrich(rec)
1568
+ if not _overlap_ok(base):
1569
+ continue
1570
+ out = enrich_miss(enrich_run(stamp_attempt(base)))
1052
1571
  if out is None: # a refused miss-amend (§5.5.7) — dropped, never appended
1053
1572
  continue
1054
1573
  line = json.dumps(out, separators=(",", ":"), ensure_ascii=False)
@@ -1072,4 +1591,12 @@ PY
1072
1591
  )"
1073
1592
  python3 -c "$TF_PROG" "$MET_DIR" "$STREAM" "$ROOT" 2>"$TF_ERR"
1074
1593
 
1594
+ # The readable miss list beside the record (FR-31, Session 5 2026-09-07): after any write to the
1595
+ # misses stream, docs/<App>-Misses.md is rebuilt from the stream, so the file is never older than
1596
+ # the record. Markdown only — the miss log is an agent document and is never rendered to HTML
1597
+ # (owner, 2026-09-08). Best effort, like everything else here.
1598
+ if [[ "$STREAM" == "misses" ]]; then
1599
+ python3 "$(dirname "${BASH_SOURCE[0]}")/tf-misses-md.py" --root "$ROOT" --quiet 2>"$TF_ERR" || true
1600
+ fi
1601
+
1075
1602
  exit 0