@techierathore/techieflow 1.0.0

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 (151) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +97 -0
  2. package/.claude/commands/TechieFlow/agents/architect.md +84 -0
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +217 -0
  4. package/.claude/commands/TechieFlow/agents/verifier.md +72 -0
  5. package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +138 -0
  6. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +98 -0
  7. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +78 -0
  8. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +77 -0
  9. package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +117 -0
  10. package/.claude/commands/TechieFlow/tasks/amend-docs.md +149 -0
  11. package/.claude/commands/TechieFlow/tasks/author-brd.md +79 -0
  12. package/.claude/commands/TechieFlow/tasks/build-phase.md +231 -0
  13. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +278 -0
  14. package/.claude/commands/TechieFlow/tasks/create-doc.md +101 -0
  15. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +507 -0
  16. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +224 -0
  17. package/.claude/commands/TechieFlow/tasks/devguide.md +224 -0
  18. package/.claude/commands/TechieFlow/tasks/document-project.md +343 -0
  19. package/.claude/commands/TechieFlow/tasks/execute-checklist.md +86 -0
  20. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +136 -0
  21. package/.claude/commands/TechieFlow/tasks/fix-issues.md +134 -0
  22. package/.claude/commands/TechieFlow/tasks/generate-html.md +115 -0
  23. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +126 -0
  24. package/.claude/commands/TechieFlow/tasks/index-docs.md +173 -0
  25. package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +75 -0
  26. package/.claude/commands/TechieFlow/tasks/log-miss.md +160 -0
  27. package/.claude/commands/TechieFlow/tasks/metrics-report.md +143 -0
  28. package/.claude/commands/TechieFlow/tasks/mockups.md +122 -0
  29. package/.claude/commands/TechieFlow/tasks/productguide.md +105 -0
  30. package/.claude/commands/TechieFlow/tasks/refresh-status.md +135 -0
  31. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +117 -0
  32. package/.claude/commands/TechieFlow/tasks/shard-doc.md +185 -0
  33. package/.claude/commands/TechieFlow/tasks/split-brd.md +131 -0
  34. package/.claude/commands/TechieFlow/tasks/triage-issues.md +157 -0
  35. package/.claude/commands/TechieFlow/tasks/verify-phase.md +579 -0
  36. package/.claude/commands/generate-html.md +124 -0
  37. package/.codex/config.toml +46 -0
  38. package/.codex/hooks.json +63 -0
  39. package/.codex/rules/techieflow.rules +8 -0
  40. package/.opencode/command/generate-html.md +129 -0
  41. package/.opencode/plugin/techieflow.js +350 -0
  42. package/.tfcore/TOKEN-GUIDE.md +59 -0
  43. package/.tfcore/agents/analyst.md +97 -0
  44. package/.tfcore/agents/architect.md +84 -0
  45. package/.tfcore/agents/flow-master.md +217 -0
  46. package/.tfcore/agents/verifier.md +72 -0
  47. package/.tfcore/checklists/architect-checklist.md +438 -0
  48. package/.tfcore/core-config.yaml +47 -0
  49. package/.tfcore/data/brainstorming-techniques.md +36 -0
  50. package/.tfcore/data/elicitation-methods.md +154 -0
  51. package/.tfcore/data/techieflow-kb.md +807 -0
  52. package/.tfcore/data/technical-preferences.md +3 -0
  53. package/.tfcore/enhanced-ide-development-workflow.md +248 -0
  54. package/.tfcore/hooks/block-git.sh +301 -0
  55. package/.tfcore/hooks/codex-adapter.py +248 -0
  56. package/.tfcore/hooks/guard-artifacts.sh +101 -0
  57. package/.tfcore/hooks/guard-status-html.sh +102 -0
  58. package/.tfcore/hooks/guard-status.sh +124 -0
  59. package/.tfcore/hooks/guard-verify.sh +108 -0
  60. package/.tfcore/hooks/metrics-session.sh +129 -0
  61. package/.tfcore/hooks/session-pointer.sh +52 -0
  62. package/.tfcore/hooks/sweep-artifacts.sh +198 -0
  63. package/.tfcore/install-manifest.yaml +231 -0
  64. package/.tfcore/routing.yaml +105 -0
  65. package/.tfcore/tasks/_metrics-emit-gate.md +138 -0
  66. package/.tfcore/tasks/_smoke-test-policy.md +98 -0
  67. package/.tfcore/tasks/_status-update-gate.md +78 -0
  68. package/.tfcore/tasks/_yolo-mode.md +77 -0
  69. package/.tfcore/tasks/advanced-elicitation.md +117 -0
  70. package/.tfcore/tasks/amend-docs.md +149 -0
  71. package/.tfcore/tasks/author-brd.md +79 -0
  72. package/.tfcore/tasks/build-phase.md +231 -0
  73. package/.tfcore/tasks/create-deep-research-prompt.md +278 -0
  74. package/.tfcore/tasks/create-doc.md +101 -0
  75. package/.tfcore/tasks/day1-brownfield.md +507 -0
  76. package/.tfcore/tasks/day1-greenfield.md +224 -0
  77. package/.tfcore/tasks/devguide.md +224 -0
  78. package/.tfcore/tasks/document-project.md +343 -0
  79. package/.tfcore/tasks/execute-checklist.md +86 -0
  80. package/.tfcore/tasks/facilitate-brainstorming-session.md +136 -0
  81. package/.tfcore/tasks/fix-issues.md +134 -0
  82. package/.tfcore/tasks/generate-html.md +115 -0
  83. package/.tfcore/tasks/handoff-phase.md +126 -0
  84. package/.tfcore/tasks/index-docs.md +173 -0
  85. package/.tfcore/tasks/kb-mode-interaction.md +75 -0
  86. package/.tfcore/tasks/log-miss.md +160 -0
  87. package/.tfcore/tasks/metrics-report.md +143 -0
  88. package/.tfcore/tasks/mockups.md +122 -0
  89. package/.tfcore/tasks/productguide.md +105 -0
  90. package/.tfcore/tasks/refresh-status.md +135 -0
  91. package/.tfcore/tasks/render-workflow-docs.md +117 -0
  92. package/.tfcore/tasks/shard-doc.md +185 -0
  93. package/.tfcore/tasks/split-brd.md +131 -0
  94. package/.tfcore/tasks/triage-issues.md +157 -0
  95. package/.tfcore/tasks/verify-phase.md +579 -0
  96. package/.tfcore/telemetry/SCHEMA.md +678 -0
  97. package/.tfcore/telemetry/install-metrics.sh +390 -0
  98. package/.tfcore/telemetry/pre-commit +94 -0
  99. package/.tfcore/telemetry/tf-metrics.sh +1415 -0
  100. package/.tfcore/templates/architecture-tmpl.yaml +650 -0
  101. package/.tfcore/templates/brainstorming-output-tmpl.yaml +156 -0
  102. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +476 -0
  103. package/.tfcore/templates/competitor-analysis-tmpl.yaml +306 -0
  104. package/.tfcore/templates/front-end-architecture-tmpl.yaml +218 -0
  105. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +823 -0
  106. package/.tfcore/templates/market-research-tmpl.yaml +252 -0
  107. package/.tfcore/templates/project-brief-tmpl.yaml +221 -0
  108. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +61 -0
  109. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +127 -0
  110. package/.tfcore/templates/v4custom/app-brd-tmpl.md +204 -0
  111. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +90 -0
  112. package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +33 -0
  113. package/.tfcore/templates/v4custom/app-devguide-tmpl.md +167 -0
  114. package/.tfcore/templates/v4custom/app-editorconfig-tmpl.editorconfig +44 -0
  115. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +31 -0
  116. package/.tfcore/templates/v4custom/app-productguide-tmpl.md +54 -0
  117. package/.tfcore/templates/v4custom/app-project-status-tmpl.md +98 -0
  118. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +48 -0
  119. package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +67 -0
  120. package/.tfcore/templates/v4custom/build-invocation-ladder.md +190 -0
  121. package/.tfcore/templates/v4custom/html-render-shell.md +502 -0
  122. package/.tfcore/templates/v4custom/metrics-report-template.md +273 -0
  123. package/.tfcore/templates/v4custom/opencode-operating-contract.md +56 -0
  124. package/.tfcore/user-guide.md +633 -0
  125. package/.tfcore/utils/techieflow-doc-template.md +325 -0
  126. package/.tfcore/utils/tf-assets.sh +310 -0
  127. package/.tfcore/utils/tf-codex-bind.py +196 -0
  128. package/.tfcore/utils/tf-codex-telemetry.py +60 -0
  129. package/.tfcore/utils/tf-emit.sh +1075 -0
  130. package/.tfcore/utils/tf-gitignore-audit.sh +281 -0
  131. package/.tfcore/utils/tf-goal.sh +421 -0
  132. package/.tfcore/utils/tf-harness.sh +160 -0
  133. package/.tfcore/utils/tf-mockup-parity.mjs +555 -0
  134. package/.tfcore/utils/tf-mockup-parity.sh +65 -0
  135. package/.tfcore/utils/tf-opencode-audit.py +126 -0
  136. package/.tfcore/utils/tf-perf.sh +241 -0
  137. package/.tfcore/utils/tf-render-html.py +715 -0
  138. package/.tfcore/utils/tf-render-html.sh +28 -0
  139. package/.tfcore/utils/tf-routing-bind.sh +216 -0
  140. package/.tfcore/utils/tf-routing.sh +247 -0
  141. package/.tfcore/utils/tf-yolo.sh +119 -0
  142. package/.tfcore/working-in-the-brownfield.md +606 -0
  143. package/LICENSE +201 -0
  144. package/README.md +1212 -0
  145. package/WORKFLOW.html +1986 -0
  146. package/docs/TechieFlow-Installation.md +299 -0
  147. package/opencode.jsonc +929 -0
  148. package/package.json +60 -0
  149. package/scripts/install.mjs +992 -0
  150. package/scripts/test-install.mjs +505 -0
  151. package/scripts/validate.mjs +155 -0
@@ -0,0 +1,1075 @@
1
+ #!/usr/bin/env bash
2
+ # TechieFlow telemetry — THE append primitive.
3
+ #
4
+ # Every component that writes telemetry calls this and ONLY this. Schema:
5
+ # .tfcore/telemetry/SCHEMA.md. Doctrine: .tfcore/tasks/_metrics-emit-gate.md.
6
+ #
7
+ # USAGE
8
+ # echo '{"kind":"run","app":"TrSetup","cmd":"build-phase"}' | tf-emit.sh runs
9
+ # tf-emit.sh --next-attempt REQ-UI-004 # prints an integer on stdout (gate attempt per REQ)
10
+ # tf-emit.sh --next-run-attempt fix-issues REQ-UI-004 [REQ-...]
11
+ # # prints an integer: the `attempt` the NEXT run record for
12
+ # # this cmd + these REQs would get — the launch-time read
13
+ # # behind routing.yaml `escalation:` (advisory)
14
+ # tf-emit.sh --next-miss-id # prints MISS-<app>-<YYYYMMDD>-<NN> (SCHEMA.md §5.5.1)
15
+ # tf-emit.sh --next-fix-attempt MISS-App-20260828-03 # prints an integer
16
+ # tf-emit.sh --open-miss REQ-UI-014 # prints "<miss_id> <miss_class>" if a miss on that REQ
17
+ # # is still open, nothing otherwise (the §5.5.4 collapse
18
+ # # check — one defect is one miss, however often it fails)
19
+ # tf-emit.sh --amend MISS-App-20260828-01 why_missed missing-checklist-item
20
+ # # fills a field that is still null on a miss already on
21
+ # # the stream (SCHEMA.md §5.5.7). Never overwrites.
22
+ # tf-emit.sh --where # prints the resolved docs/metrics dir
23
+ #
24
+ # STREAMS: runs | gates | sessions | commits | misses (anything else is dropped)
25
+ #
26
+ # misses.jsonl is the one stream carrying THREE record kinds — `miss` (opened),
27
+ # `miss-fix` (closed, linked by miss_id) and `miss-amend` (completes a field the
28
+ # `miss` left null — never overwrites one, so it can add information to the
29
+ # history without altering it). SCHEMA.md §5.5.
30
+ #
31
+ # ONE RECORD OR MANY. stdin is normally a single JSON object. It may also be a
32
+ # JSONL *stream* — one object per line — and every line is appended in ONE
33
+ # process. That exists for the reconcilers (the post-commit hook and
34
+ # tf-metrics.sh --backfill-*), which can have hundreds of records to write and
35
+ # used to pay a bash+python startup per record. A malformed line is dropped on
36
+ # its own; the rest of the batch still lands.
37
+ #
38
+ # WHAT IT INJECTS when the record does not already carry them:
39
+ # v -> 1
40
+ # ts -> now, ISO-8601 UTC, second precision, Z suffix
41
+ # project_type -> core-config.yaml : metrics.project_type
42
+ # project_type_inferred -> true, ONLY when metrics.project_type was absent
43
+ # app -> inferred from docs/<App>-Checklist.md, else repo dir name
44
+ # harness -> claude-code | opencode | null, DETECTED not declared
45
+ # (a task template cannot know which harness is running it,
46
+ # so it must never hard-code this; null when undeterminable,
47
+ # because a wrong harness label is worse than a missing one)
48
+ # origin_model -> on `miss` records: LOOKED UP from the runs.jsonl record whose
49
+ # origin_harness `started` == origin_run_id. Never written by an agent, for the
50
+ # same reason as `harness`: shared task markdown cannot know it,
51
+ # and a wrong model label corrupts every per-model comparison.
52
+ # tokens_* / cost_usd -> on `miss-fix` records: COPIED from the runs.jsonl record whose
53
+ # tokens_scope / model `started` == fix_run_id (that run's own window, already
54
+ # enriched at its append). Never recomputed, never estimated.
55
+ # Nothing else is ever added, reordered, or rewritten.
56
+ #
57
+ # HARD RULE — TELEMETRY HAS NO VETO. This script exits 0 UNCONDITIONALLY:
58
+ # missing dir, unreadable file, malformed JSON, absent python3, full disk. On any
59
+ # error the event is DROPPED and the caller continues, exactly the way
60
+ # guard-status.sh / guard-verify.sh already fail open. A telemetry bug must never
61
+ # cost the owner a working session.
62
+ # TF_METRICS_DEBUG=1 -> explain a drop on stderr (still exits 0).
63
+ #
64
+ # NO GIT. This script never invokes git, and neither does anything that calls it.
65
+ # The only git-derived stream comes from the owner's own post-commit hook.
66
+
67
+ set +e
68
+
69
+ TF_DEBUG="${TF_METRICS_DEBUG:-}"
70
+ _warn() { [[ -n "$TF_DEBUG" ]] && printf 'tf-emit: %s\n' "$1" >&2; return 0; }
71
+ # Where python's stderr goes: nowhere unless the owner asked to see it.
72
+ TF_ERR=/dev/null
73
+ [[ -n "$TF_DEBUG" ]] && TF_ERR=/dev/stderr
74
+
75
+ # --- resolve the app repo root -------------------------------------------
76
+ # Explicit override first, then the harness's project dir, then walk up looking
77
+ # for a TechieFlow-shaped repo, then give up on the cwd.
78
+ _find_root() {
79
+ if [[ -n "$TF_METRICS_ROOT" && -d "$TF_METRICS_ROOT" ]]; then
80
+ printf '%s' "$TF_METRICS_ROOT"; return 0
81
+ fi
82
+ if [[ -n "$CLAUDE_PROJECT_DIR" && -d "$CLAUDE_PROJECT_DIR" ]]; then
83
+ printf '%s' "$CLAUDE_PROJECT_DIR"; return 0
84
+ fi
85
+ local d; d="$(pwd -P 2>/dev/null)" || return 1
86
+ while [[ -n "$d" && "$d" != "/" ]]; do
87
+ if [[ -d "$d/.tfcore" || -d "$d/docs" ]]; then printf '%s' "$d"; return 0; fi
88
+ d="$(dirname "$d")"
89
+ done
90
+ pwd -P 2>/dev/null
91
+ }
92
+
93
+ ROOT="$(_find_root)" || { _warn "cannot resolve repo root"; exit 0; }
94
+ [[ -n "$ROOT" ]] || { _warn "empty repo root"; exit 0; }
95
+ MET_DIR="$ROOT/docs/metrics"
96
+
97
+ case "$1" in
98
+ --where) printf '%s\n' "$MET_DIR"; exit 0 ;;
99
+ esac
100
+
101
+ command -v python3 >/dev/null 2>&1 || { _warn "python3 absent — event dropped"; exit 0; }
102
+
103
+ # --- read helper: next attempt number for a REQ --------------------------
104
+ # attempt = 1 + count of prior NON-BACKFILLED gate records for this req_id.
105
+ # Backfilled records are excluded on purpose (SCHEMA.md §3.1) — a backfilled
106
+ # attempt is itself inferred, and counting it would propagate a guess into a
107
+ # live record. Prints an integer; prints 1 on any failure (never blocks).
108
+ if [[ "$1" == "--next-attempt" ]]; then
109
+ REQ="$2"
110
+ [[ -n "$REQ" ]] || { echo 1; exit 0; }
111
+ python3 - "$MET_DIR/gates.jsonl" "$REQ" 2>"$TF_ERR" <<'PY' || echo 1
112
+ import json, sys
113
+ path, req = sys.argv[1], sys.argv[2]
114
+ n = 0
115
+ try:
116
+ with open(path, "r", encoding="utf-8") as fh:
117
+ for line in fh:
118
+ line = line.strip()
119
+ if not line:
120
+ continue
121
+ try:
122
+ r = json.loads(line)
123
+ except Exception:
124
+ continue
125
+ if r.get("kind") == "gate" and r.get("req_id") == req and not r.get("backfilled"):
126
+ n += 1
127
+ except FileNotFoundError:
128
+ pass
129
+ print(n + 1)
130
+ PY
131
+ exit 0
132
+ fi
133
+
134
+ # --- read helper: next RUN attempt number for a cmd + REQ set ------------
135
+ # run attempt = 1 + count of prior NON-BACKFILLED `run` records with the same
136
+ # cmd whose reqs_touched intersects the given REQ IDs. Same counting rule that
137
+ # enrich_run() below stamps into `attempt` at append time, exposed so the
138
+ # escalation policy in routing.yaml can be applied BEFORE a run is launched
139
+ # (advisory; DECISIONS.md 2026-08-21). Prints 1 on any failure (never blocks).
140
+ if [[ "$1" == "--next-run-attempt" ]]; then
141
+ CMD="$2"; shift 2 2>/dev/null
142
+ [[ -n "$CMD" && $# -gt 0 ]] || { echo 1; exit 0; }
143
+ python3 - "$MET_DIR/runs.jsonl" "$CMD" "$@" 2>"$TF_ERR" <<'PY' || echo 1
144
+ import json, sys
145
+ path, cmd, reqs = sys.argv[1], sys.argv[2], set(sys.argv[3:])
146
+ n = 0
147
+ try:
148
+ with open(path, "r", encoding="utf-8") as fh:
149
+ for line in fh:
150
+ line = line.strip()
151
+ if not line:
152
+ continue
153
+ try:
154
+ r = json.loads(line)
155
+ except Exception:
156
+ continue
157
+ if (r.get("kind") == "run" and r.get("cmd") == cmd and not r.get("backfilled")
158
+ and reqs & set(r.get("reqs_touched") or [])):
159
+ n += 1
160
+ except FileNotFoundError:
161
+ pass
162
+ print(n + 1)
163
+ PY
164
+ exit 0
165
+ fi
166
+
167
+ # --- read helper: the next miss id ---------------------------------------
168
+ # MISS-<app>-<YYYYMMDD>-<NN>, NN = 1 + today's existing miss records for this app.
169
+ # An agent must never invent one: miss_id is the join key between a `miss` and its
170
+ # `miss-fix`, and a collision would silently merge two defects into one lifecycle.
171
+ if [[ "$1" == "--next-miss-id" ]]; then
172
+ python3 - "$MET_DIR/misses.jsonl" "$ROOT" 2>"$TF_ERR" <<'PY' || true
173
+ import datetime, glob, json, os, sys
174
+ path, root = sys.argv[1], sys.argv[2]
175
+ hits = glob.glob(os.path.join(root, "docs", "*-Checklist.md"))
176
+ app = (os.path.basename(hits[0])[: -len("-Checklist.md")] if len(hits) == 1
177
+ else os.path.basename(os.path.abspath(root)))
178
+ day = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d")
179
+ stem = "MISS-%s-%s-" % (app, day)
180
+ n = 0
181
+ try:
182
+ with open(path, encoding="utf-8") as fh:
183
+ for line in fh:
184
+ line = line.strip()
185
+ if not line:
186
+ continue
187
+ try:
188
+ r = json.loads(line)
189
+ except Exception:
190
+ continue
191
+ mid = r.get("miss_id") or ""
192
+ if r.get("kind") == "miss" and mid.startswith(stem):
193
+ try:
194
+ n = max(n, int(mid[len(stem):]))
195
+ except ValueError:
196
+ pass
197
+ except FileNotFoundError:
198
+ pass
199
+ print("%s%02d" % (stem, n + 1))
200
+ PY
201
+ exit 0
202
+ fi
203
+
204
+ # --- read helper: next fix attempt for a miss ----------------------------
205
+ if [[ "$1" == "--next-fix-attempt" ]]; then
206
+ MID="$2"
207
+ [[ -n "$MID" ]] || { echo 1; exit 0; }
208
+ python3 - "$MET_DIR/misses.jsonl" "$MID" 2>"$TF_ERR" <<'PY' || echo 1
209
+ import json, sys
210
+ path, mid = sys.argv[1], sys.argv[2]
211
+ n = 0
212
+ try:
213
+ with open(path, encoding="utf-8") as fh:
214
+ for line in fh:
215
+ line = line.strip()
216
+ if not line:
217
+ continue
218
+ try:
219
+ r = json.loads(line)
220
+ except Exception:
221
+ continue
222
+ if r.get("kind") == "miss-fix" and r.get("miss_id") == mid:
223
+ n += 1
224
+ except FileNotFoundError:
225
+ pass
226
+ print(n + 1)
227
+ PY
228
+ exit 0
229
+ fi
230
+
231
+ # --- read helper: is a miss on this REQ still open? -----------------------
232
+ # SCHEMA.md §5.5.4 — the collapse check. A REQ that fails three verify passes
233
+ # must produce ONE miss, not three; otherwise the miss count measures retry
234
+ # patience rather than quality. Open = no miss-fix, or the latest miss-fix
235
+ # closed with a verdict_after other than "Verified". Prints "<miss_id>
236
+ # <miss_class>" for the most recent open miss, or nothing at all.
237
+ if [[ "$1" == "--open-miss" ]]; then
238
+ REQ="$2"
239
+ [[ -n "$REQ" ]] || exit 0
240
+ python3 - "$MET_DIR/misses.jsonl" "$REQ" 2>"$TF_ERR" <<'PY' || true
241
+ import json, sys
242
+ path, req = sys.argv[1], sys.argv[2]
243
+ opened, fixes = [], {}
244
+ try:
245
+ with open(path, encoding="utf-8") as fh:
246
+ for line in fh:
247
+ line = line.strip()
248
+ if not line:
249
+ continue
250
+ try:
251
+ r = json.loads(line)
252
+ except Exception:
253
+ continue
254
+ if r.get("kind") == "miss" and r.get("req_id") == req:
255
+ opened.append(r)
256
+ elif r.get("kind") == "miss-fix":
257
+ mid = r.get("miss_id")
258
+ if mid:
259
+ prev = fixes.get(mid)
260
+ if prev is None or (r.get("ts") or "") >= (prev.get("ts") or ""):
261
+ fixes[mid] = r
262
+ except FileNotFoundError:
263
+ pass
264
+ for r in reversed(opened): # most recent first
265
+ fix = fixes.get(r.get("miss_id"))
266
+ if fix is None or fix.get("verdict_after") != "Verified":
267
+ print("%s %s" % (r.get("miss_id"), r.get("miss_class") or "other"))
268
+ break
269
+ PY
270
+ exit 0
271
+ fi
272
+
273
+ # --- write helper: complete a field that was left null --------------------
274
+ # SCHEMA.md §5.5.7. The ONLY sanctioned way to fill a field on a record that is
275
+ # already on the stream — and the reason constraint 5's "the correction is a new
276
+ # record, never an edit" now names something that exists for this stream.
277
+ #
278
+ # tf-emit.sh --amend MISS-App-20260828-01 why_missed missing-checklist-item
279
+ #
280
+ # Refuses, out loud and on stdout, when the field is not amendable, the value is
281
+ # outside its closed vocabulary, no parent miss exists, or the field already has
282
+ # a value. It COMPLETES a record; it never alters a fact. Exits 0 either way —
283
+ # telemetry has no veto (§10), and neither does a refused amend.
284
+ if [[ "$1" == "--amend" ]]; then
285
+ AMID="$2"; AFLD="$3"; AVAL="$4"
286
+ if [[ -z "$AMID" || -z "$AFLD" || -z "$AVAL" ]]; then
287
+ echo "tf-emit: --amend needs <miss_id> <field> <value>"
288
+ exit 0
289
+ fi
290
+ AREC="$(python3 - "$MET_DIR/misses.jsonl" "$AMID" "$AFLD" "$AVAL" 2>"$TF_ERR" <<'PY' || true
291
+ import json, sys
292
+ path, mid, fld, val = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
293
+
294
+ # THE ALLOWLIST, and the rule for extending it (SCHEMA.md §5.5.7).
295
+ # A field is amendable only when it is (a) a closed-vocabulary JUDGEMENT that a
296
+ # reader can still make correctly later, and (b) not derived by this emitter.
297
+ # An OBSERVATION is never amendable — §3.5's rule for a gate added mid-stream
298
+ # ("never backfill the old records with a verdict they never had") is the same
299
+ # rule seen from the other side: `why_missed` is a classification an analyst can
300
+ # still make honestly next week; `found_gate` is a fact about a run that is over.
301
+ # Attribution and cost fields are excluded outright: the emitter derives those,
302
+ # and an amend that could set them would be a hole straight through §5.5.1.
303
+ AMENDABLE = {
304
+ "why_missed": ("missing-checklist-item", "insufficient-verify-method",
305
+ "code-audit-limitation", "ambiguous-acceptance",
306
+ "dependency-not-declared", "instruction-ignored", "other"),
307
+ }
308
+ if fld not in AMENDABLE:
309
+ print("REFUSED %s is not an amendable field (SCHEMA.md §5.5.7)" % fld); raise SystemExit(0)
310
+ if val not in AMENDABLE[fld]:
311
+ print("REFUSED %r is not in the closed vocabulary for %s" % (val, fld)); raise SystemExit(0)
312
+
313
+ parent, current = None, None
314
+ try:
315
+ with open(path, encoding="utf-8") as fh:
316
+ for line in fh:
317
+ line = line.strip()
318
+ if not line:
319
+ continue
320
+ try:
321
+ r = json.loads(line)
322
+ except Exception:
323
+ continue
324
+ if r.get("kind") == "miss" and r.get("miss_id") == mid:
325
+ parent = r
326
+ current = r.get(fld)
327
+ elif (r.get("kind") == "miss-amend" and r.get("miss_id") == mid
328
+ and r.get("field") == fld):
329
+ current = r.get("value") # an earlier amend already set it
330
+ except FileNotFoundError:
331
+ pass
332
+
333
+ if parent is None:
334
+ print("REFUSED no miss record %s on this stream" % mid); raise SystemExit(0)
335
+ if current is not None:
336
+ print("REFUSED %s is already %r on %s — an amend completes a record, never "
337
+ "overwrites a value" % (fld, current, mid)); raise SystemExit(0)
338
+
339
+ print("OK " + json.dumps({"kind": "miss-amend", "miss_id": mid,
340
+ "field": fld, "value": val},
341
+ separators=(",", ":"), ensure_ascii=False))
342
+ PY
343
+ )"
344
+ case "$AREC" in
345
+ "OK "*) printf '%s' "${AREC#OK }" | bash "${BASH_SOURCE[0]}" misses
346
+ echo "tf-emit: amended $AMID — $AFLD = $AVAL" ;;
347
+ "REFUSED "*) echo "tf-emit: amend refused — ${AREC#REFUSED }" ;;
348
+ *) echo "tf-emit: amend could not be evaluated — nothing written" ;;
349
+ esac
350
+ exit 0
351
+ fi
352
+
353
+ # --- append path ----------------------------------------------------------
354
+ STREAM="$1"
355
+ case "$STREAM" in
356
+ runs|gates|sessions|commits|misses) ;;
357
+ *) _warn "unknown stream '${STREAM:-<none>}' — event dropped"; exit 0 ;;
358
+ esac
359
+
360
+ # The program is captured into a variable rather than fed on stdin — a heredoc
361
+ # on `python3 -` would REPLACE the caller's piped JSON and silently drop every event.
362
+ TF_PROG="$(cat <<'PY'
363
+ import json, os, sys, datetime, glob, re
364
+
365
+ met_dir, stream, root = sys.argv[1], sys.argv[2], sys.argv[3]
366
+ dbg = os.environ.get("TF_METRICS_DEBUG")
367
+
368
+ def warn(msg):
369
+ if dbg:
370
+ sys.stderr.write("tf-emit: %s\n" % msg)
371
+
372
+ raw = sys.stdin.read()
373
+ if not raw.strip():
374
+ warn("empty stdin — event dropped")
375
+ raise SystemExit(0)
376
+
377
+ # One object, or a JSONL stream of them. The single-object form is tried first so
378
+ # a pretty-printed record spanning several lines still works exactly as before;
379
+ # only if that fails is the input read line-by-line as a batch.
380
+ records = []
381
+ try:
382
+ one = json.loads(raw)
383
+ records = one if isinstance(one, list) else [one]
384
+ except Exception as whole_err:
385
+ for n, line in enumerate(raw.splitlines(), 1):
386
+ line = line.strip()
387
+ if not line:
388
+ continue
389
+ try:
390
+ records.append(json.loads(line))
391
+ except Exception as e:
392
+ warn("stdin line %d is not valid JSON (%s) — line dropped" % (n, e))
393
+ if not records:
394
+ warn("stdin is not valid JSON (%s) — event dropped" % whole_err)
395
+ raise SystemExit(0)
396
+
397
+ records = [r for r in records if isinstance(r, dict)]
398
+ if not records:
399
+ warn("no JSON object in stdin — event dropped")
400
+ raise SystemExit(0)
401
+
402
+ # --- values resolved ONCE for the whole batch ----------------------------
403
+ # project_type, harness and app are properties of the repo and the process, not
404
+ # of the individual record, so a 400-record reconcile reads core-config.yaml and
405
+ # walks the process tree once rather than 400 times.
406
+
407
+ # project_type from core-config.yaml : metrics.project_type
408
+ # metrics:
409
+ # project_type: app
410
+ def _project_type():
411
+ # STRUCTURAL SIGNAL FIRST, and it is authoritative. A repo carrying the
412
+ # scaffold scripts beside .tfcore/tasks/ IS the framework template — nothing
413
+ # else can be, and a scaffolded app never has them at its root.
414
+ #
415
+ # This is checked ahead of core-config.yaml, and deliberately: the framework's
416
+ # own core-config.yaml is the file the scaffolds rsync into a new app with
417
+ # --ignore-existing, so recording `project_type: framework` in it would stamp
418
+ # `framework` on every app created afterwards, and install-metrics.sh would
419
+ # then never re-guess (an existing classification always wins). Detecting it
420
+ # from the tree shape instead means the framework can classify itself without
421
+ # anything to leak. install-metrics.sh knows the same rule and writes nothing.
422
+ try:
423
+ if (os.path.isfile(os.path.join(root, "scaffold-brownfield.sh"))
424
+ and os.path.isdir(os.path.join(root, ".tfcore", "tasks"))):
425
+ return "framework"
426
+ except Exception:
427
+ pass
428
+ cfg = os.path.join(root, ".tfcore", "core-config.yaml")
429
+ try:
430
+ with open(cfg, "r", encoding="utf-8") as fh:
431
+ text = fh.read()
432
+ m = re.search(
433
+ r"^metrics:[ \t]*$(?:\n(?:[ \t]+.*|[ \t]*))*?\n[ \t]+project_type:[ \t]*"
434
+ r"[\"']?(app|library|docs|framework)[\"']?[ \t]*$",
435
+ text,
436
+ re.M,
437
+ )
438
+ if m:
439
+ return m.group(1)
440
+ except Exception:
441
+ pass
442
+ return None
443
+
444
+ PTYPE = _project_type()
445
+
446
+ # harness — DETECTED, never taken on trust. The task markdown is shared by all
447
+ # harnesses, so an agent copying a template literal would stamp whichever harness
448
+ # the example happened to name. Detection order: harness env vars, then the parent
449
+ # process chain (OpenCode sets no OPENCODE_* vars, so the process name is the only
450
+ # honest signal). Undeterminable -> null: a wrong label silently corrupts any
451
+ # per-harness comparison, a missing one is merely missing.
452
+ def _detect_harness():
453
+ # TF_HARNESS is set by the harness bridge itself (the OpenCode plugin's
454
+ # shell.env / guard spawns — .opencode/plugin/techieflow.js), never by an
455
+ # agent, and beats the marker-variable scan: a process launched from inside
456
+ # the OTHER harness inherits that harness's markers (e.g. OpenCode started
457
+ # from a Claude Code bash still carries CLAUDE_PROJECT_DIR).
458
+ tf = os.environ.get("TF_HARNESS")
459
+ if tf in ("claude-code", "opencode", "codex"):
460
+ return tf
461
+ for k in ("CODEX_THREAD_ID", "CODEX_SESSION_ID"):
462
+ if os.environ.get(k):
463
+ return "codex"
464
+ for k in ("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION_ID",
465
+ "CLAUDE_PROJECT_DIR"):
466
+ if os.environ.get(k):
467
+ return "claude-code"
468
+ for k in os.environ:
469
+ if k.startswith("OPENCODE"):
470
+ return "opencode"
471
+ # Walk the process ancestry (bounded). Linux/WSL via /proc; macOS via ps.
472
+ try:
473
+ pid, seen = os.getppid(), 0
474
+ while pid and pid > 1 and seen < 12:
475
+ seen += 1
476
+ name = ppid = None
477
+ try:
478
+ with open("/proc/%d/stat" % pid) as fh:
479
+ stat = fh.read()
480
+ name = stat[stat.find("(") + 1: stat.rfind(")")]
481
+ ppid = int(stat[stat.rfind(")") + 2:].split()[1])
482
+ except Exception:
483
+ import subprocess
484
+ out = subprocess.run(["ps", "-o", "comm=,ppid=", "-p", str(pid)],
485
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
486
+ parts = out.stdout.decode("utf-8", "replace").split()
487
+ if len(parts) >= 2:
488
+ name, ppid = os.path.basename(parts[0]), int(parts[1])
489
+ if name and "opencode" in name.lower():
490
+ return "opencode"
491
+ if name and "codex" in name.lower():
492
+ return "codex"
493
+ if name and "claude" in name.lower():
494
+ return "claude-code"
495
+ pid = ppid
496
+ except Exception:
497
+ pass
498
+ return None
499
+
500
+ try:
501
+ HARNESS = _detect_harness()
502
+ except Exception:
503
+ HARNESS = None
504
+
505
+ # app name — inferred from the one checklist, else the repo directory name
506
+ def _app_name():
507
+ try:
508
+ hits = glob.glob(os.path.join(root, "docs", "*-Checklist.md"))
509
+ if len(hits) == 1:
510
+ return os.path.basename(hits[0])[: -len("-Checklist.md")]
511
+ except Exception:
512
+ pass
513
+ return None
514
+
515
+ APP = _app_name() or os.path.basename(os.path.abspath(root))
516
+
517
+ NOW = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
518
+
519
+
520
+ def enrich(rec):
521
+ """Fill the injected fields on ONE record. Nothing else is ever added,
522
+ reordered, or rewritten — except the per-run window/tier fields added by
523
+ enrich_run() below for runs/gates records (docs/Telemetry-Hooks.md §2-§4,
524
+ DECISIONS.md 2026-08-20)."""
525
+ rec.setdefault("v", 1)
526
+ rec.setdefault("ts", NOW)
527
+ if "project_type" not in rec:
528
+ if PTYPE:
529
+ rec["project_type"] = PTYPE
530
+ else:
531
+ # Default, but NEVER silently — the report labels these unclassified.
532
+ rec["project_type"] = "app"
533
+ rec["project_type_inferred"] = True
534
+ rec.setdefault("harness", HARNESS)
535
+ rec.setdefault("app", APP)
536
+ return rec
537
+
538
+ # --- per-run tier + token-window enrichment (runs/gates only) -------------
539
+ # Declared tier comes from .tfcore/routing.yaml (only when enabled: true).
540
+ # Observed model/tokens come from the harness's own store, windowed on the
541
+ # record's started/ended: Claude Code = the transcript named by the session
542
+ # pointer .tfcore/.session/claude-code.json (main thread only -> scope "main");
543
+ # OpenCode = opencode.db messages for the pointer session + its descendants
544
+ # (scope "tree", real cost). Every failure degrades to tokens_scope:"none" —
545
+ # tokens are NEVER estimated. Dollars are never computed on Claude (no source).
546
+
547
+ def _routing():
548
+ out = {"enabled": False, "phases": {}, "subagents": {}, "tiers": {}}
549
+ try:
550
+ sect, tier = None, None
551
+ with open(os.path.join(root, ".tfcore", "routing.yaml"), encoding="utf-8") as fh:
552
+ for line in fh:
553
+ line = line.rstrip("\n")
554
+ if not line.strip() or line.lstrip().startswith("#"):
555
+ continue
556
+ if not line.startswith(" "):
557
+ key, _, val = line.partition(":")
558
+ sect, tier = key.strip(), None
559
+ if sect == "enabled":
560
+ out["enabled"] = val.strip() == "true"
561
+ elif sect == "tiers":
562
+ if re.match(r"^ [a-z-]+:\s*$", line):
563
+ tier = line.strip()[:-1]
564
+ out["tiers"][tier] = {}
565
+ elif tier and line.startswith(" "):
566
+ k, _, v = line.strip().partition(":")
567
+ out["tiers"][tier][k.strip()] = v.strip()
568
+ elif sect in ("phases", "subagents"):
569
+ k, _, v = line.strip().partition(":")
570
+ out[sect][k.strip()] = v.strip()
571
+ except Exception:
572
+ pass
573
+ return out
574
+
575
+ ROUTING = _routing()
576
+
577
+ def _iso_ms(s):
578
+ try:
579
+ return int(datetime.datetime.strptime(
580
+ s.replace("Z", "+0000"), "%Y-%m-%dT%H:%M:%S%z").timestamp() * 1000)
581
+ except Exception:
582
+ return None
583
+
584
+ def _pointer(name):
585
+ try:
586
+ with open(os.path.join(root, ".tfcore", ".session", name + ".json"),
587
+ encoding="utf-8") as fh:
588
+ return json.load(fh)
589
+ except Exception:
590
+ return None
591
+
592
+ def _sum_claude_transcript(path, t0, t1, tot, models):
593
+ """Sum one transcript's in-window assistant messages. Returns the OUTPUT tokens
594
+ this transcript alone contributed, so the caller can split main from subagents
595
+ (SCHEMA.md §2.5 `tokens_out_subagents`) without walking the file twice."""
596
+ before = tot["out"]
597
+ with open(path, encoding="utf-8", errors="replace") as fh:
598
+ for line in fh:
599
+ if '"assistant"' not in line:
600
+ continue
601
+ try:
602
+ r = json.loads(line)
603
+ except Exception:
604
+ continue
605
+ if r.get("type") != "assistant":
606
+ continue
607
+ ts = _iso_ms((r.get("timestamp") or "").split(".")[0].rstrip("Z") + "Z")
608
+ if ts is None or ts < t0 or ts > t1:
609
+ continue
610
+ msg = r.get("message") or {}
611
+ u = msg.get("usage") or {}
612
+ o = u.get("output_tokens") or 0
613
+ tot["in"] += u.get("input_tokens") or 0
614
+ tot["out"] += o
615
+ tot["cr"] += u.get("cache_read_input_tokens") or 0
616
+ tot["cw"] += u.get("cache_creation_input_tokens") or 0
617
+ m = msg.get("model")
618
+ if m:
619
+ models[m] = models.get(m, 0) + o
620
+ return tot["out"] - before
621
+
622
+ def _window_claude(t0, t1):
623
+ ptr = _pointer("claude-code")
624
+ if not ptr:
625
+ return None
626
+ path = ptr.get("transcript_path")
627
+ if not path or not os.path.isfile(path):
628
+ return None
629
+ tot = {"in": 0, "out": 0, "cr": 0, "cw": 0}
630
+ models = {}
631
+ scope = "main"
632
+ sub_runs, sub_out = 0, 0
633
+ try:
634
+ _sum_claude_transcript(path, t0, t1, tot, models)
635
+ # Subagent transcripts live at a deterministic path beside the parent's:
636
+ # <transcript-dir>/<session-id>/subagents/agent-*.jsonl (same JSONL
637
+ # format; verified 2026-08-20 via a SubagentStop payload's
638
+ # agent_transcript_path — DECISIONS.md 2026-08-20). Including them
639
+ # upgrades the window from "main" to "tree" with no hook needed.
640
+ if path.endswith(".jsonl"):
641
+ subdir = os.path.join(path[:-len(".jsonl")], "subagents")
642
+ if os.path.isdir(subdir):
643
+ scope = "tree"
644
+ for sub in glob.glob(os.path.join(subdir, "*.jsonl")):
645
+ try:
646
+ # A transcript that produced NOTHING inside this run's
647
+ # window belongs to a different run in the same session —
648
+ # counting it would make every later phase look like it
649
+ # fanned out as widely as the busiest one before it. Only
650
+ # a transcript with in-window output is this run's.
651
+ got = _sum_claude_transcript(sub, t0, t1, tot, models)
652
+ if got > 0:
653
+ sub_runs += 1
654
+ sub_out += got
655
+ except Exception:
656
+ pass
657
+ except Exception:
658
+ return None
659
+ if tot["in"] + tot["out"] == 0:
660
+ return None
661
+ return {"tot": tot, "models": models, "cost": None, "scope": scope,
662
+ "sub_runs": sub_runs, "sub_out": sub_out}
663
+
664
+ def _window_opencode(t0, t1):
665
+ ptr = _pointer("opencode")
666
+ if not ptr:
667
+ return None
668
+ dbp = ptr.get("db_path")
669
+ sid = ptr.get("session_id")
670
+ if not dbp or not sid or not os.path.isfile(dbp):
671
+ return None
672
+ try:
673
+ import sqlite3
674
+ db = sqlite3.connect("file:%s?mode=ro" % dbp, uri=True)
675
+ pairs = db.execute("SELECT id, parent_id FROM session").fetchall()
676
+ kids = {}
677
+ for i, p in pairs:
678
+ kids.setdefault(p, []).append(i)
679
+ tree, queue = {sid}, [sid]
680
+ while queue:
681
+ for c in kids.get(queue.pop(), []):
682
+ if c not in tree:
683
+ tree.add(c)
684
+ queue.append(c)
685
+ tot = {"in": 0, "out": 0, "cr": 0, "cw": 0}
686
+ models = {}
687
+ cost = 0.0
688
+ # Same rule as the Claude branch: a CHILD session counts as a subagent run
689
+ # for THIS window only if it produced output inside it.
690
+ child_out = {}
691
+ q = "SELECT session_id, data FROM message WHERE time_created BETWEEN ? AND ?"
692
+ for msid, data in db.execute(q, (t0, t1)):
693
+ if msid not in tree:
694
+ continue
695
+ try:
696
+ d = json.loads(data)
697
+ except Exception:
698
+ continue
699
+ if d.get("role") != "assistant":
700
+ continue
701
+ t = d.get("tokens") or {}
702
+ cache = t.get("cache") or {}
703
+ o = t.get("output") or 0
704
+ tot["in"] += t.get("input") or 0
705
+ tot["out"] += o
706
+ tot["cr"] += cache.get("read") or 0
707
+ tot["cw"] += cache.get("write") or 0
708
+ cost += d.get("cost") or 0
709
+ if msid != sid and o:
710
+ child_out[msid] = child_out.get(msid, 0) + o
711
+ m = (d.get("providerID", "") + "/" if d.get("providerID") else "") + (d.get("modelID") or "")
712
+ if m:
713
+ models[m] = models.get(m, 0) + o
714
+ db.close()
715
+ except Exception:
716
+ return None
717
+ if tot["in"] + tot["out"] == 0:
718
+ return None
719
+ return {"tot": tot, "models": models, "cost": round(cost, 6), "scope": "tree",
720
+ "sub_runs": len(child_out), "sub_out": sum(child_out.values())}
721
+
722
+ # --- per-run `attempt` (runs only; added 2026-08-21, SCHEMA.md §2.5) ------
723
+ # attempt = 1 + prior NON-BACKFILLED `run` records with the same cmd whose
724
+ # reqs_touched intersects this record's. Stamped only on live records that
725
+ # carry a non-empty reqs_touched; backfilled records are left as the
726
+ # reconciler wrote them. This is the counter routing.yaml `escalation:` reads
727
+ # at launch (advisory — tf-emit never changes a model, it only records).
728
+ _PRIOR_RUNS = None
729
+
730
+ def _prior_runs():
731
+ global _PRIOR_RUNS
732
+ if _PRIOR_RUNS is None:
733
+ _PRIOR_RUNS = []
734
+ try:
735
+ with open(os.path.join(met_dir, "runs.jsonl"), encoding="utf-8") as fh:
736
+ for line in fh:
737
+ line = line.strip()
738
+ if not line:
739
+ continue
740
+ try:
741
+ r = json.loads(line)
742
+ except Exception:
743
+ continue
744
+ if r.get("kind") == "run" and not r.get("backfilled"):
745
+ _PRIOR_RUNS.append((r.get("cmd"), set(r.get("reqs_touched") or [])))
746
+ except Exception:
747
+ pass
748
+ return _PRIOR_RUNS
749
+
750
+ def stamp_attempt(rec):
751
+ if stream != "runs" or "attempt" in rec or rec.get("backfilled"):
752
+ return rec
753
+ try:
754
+ reqs = set(rec.get("reqs_touched") or [])
755
+ cmd = rec.get("cmd")
756
+ if not reqs or not cmd or rec.get("kind", "run") != "run":
757
+ return rec
758
+ prior = _prior_runs()
759
+ rec["attempt"] = 1 + sum(1 for c, rs in prior if c == cmd and (rs & reqs))
760
+ prior.append((cmd, reqs)) # later records in the same batch count this one
761
+ except Exception as e:
762
+ warn("attempt stamp failed (%s) — record kept without it" % e)
763
+ return rec
764
+
765
+ def enrich_run(rec):
766
+ if stream not in ("runs", "gates"):
767
+ return rec
768
+ try:
769
+ cmd = rec.get("cmd")
770
+ if ROUTING["enabled"] and cmd and "tier" not in rec:
771
+ tier = ROUTING["phases"].get(cmd)
772
+ if tier and tier != "inherit":
773
+ rec["tier"] = tier
774
+ key = {"claude-code": "claude", "opencode": "opencode", "codex": "codex"}.get(HARNESS)
775
+ tm = ROUTING["tiers"].get(tier, {}).get(key) if key else None
776
+ if tm:
777
+ rec["tier_model"] = tm
778
+ if rec.get("started") and rec.get("ended") and "tokens_scope" not in rec:
779
+ t0, t1 = _iso_ms(rec["started"]), _iso_ms(rec["ended"])
780
+ win = None
781
+ if t0 is not None and t1 is not None and t1 >= t0:
782
+ if HARNESS == "claude-code":
783
+ win = _window_claude(t0, t1)
784
+ elif HARNESS == "opencode":
785
+ win = _window_opencode(t0, t1)
786
+ if win:
787
+ models = sorted(win["models"], key=win["models"].get, reverse=True)
788
+ if models:
789
+ rec["model"] = models[0]
790
+ if len(models) > 1:
791
+ rec["models"] = models
792
+ # The per-model OUTPUT split, not just the winner's name. A run
793
+ # that spent 90% of its output on one model and 10% on another
794
+ # is a different fact from one that split evenly, and `model` +
795
+ # `models` cannot tell them apart. Effort-per-phase-per-model is
796
+ # unanswerable without it (SCHEMA.md §2.6).
797
+ rec["model_tokens_out"] = {m: win["models"][m] for m in models}
798
+ rec["tokens_in"] = win["tot"]["in"]
799
+ rec["tokens_out"] = win["tot"]["out"]
800
+ rec["tokens_cache_read"] = win["tot"]["cr"]
801
+ rec["tokens_cache_write"] = win["tot"]["cw"]
802
+ rec["cost_usd"] = win["cost"]
803
+ rec["tokens_scope"] = win["scope"]
804
+ # MEASURED fan-out, never self-reported (SCHEMA.md §2.6). `subagents`
805
+ # is a list of names an agent types and cannot be trusted to keep in
806
+ # step with what it actually spawned; these two are counted from the
807
+ # harness's own store — the subagent transcripts beside the parent's
808
+ # on Claude, the child sessions in opencode.db on OpenCode. Absent
809
+ # whenever the window could not be computed: absent means "not
810
+ # captured", never 0.
811
+ rec["subagent_runs"] = win.get("sub_runs", 0)
812
+ rec["tokens_out_subagents"] = win.get("sub_out", 0)
813
+ if rec.get("tier_model") and rec.get("model"):
814
+ rec["routed"] = rec["model"] == rec["tier_model"]
815
+ else:
816
+ rec["tokens_scope"] = "none"
817
+ except Exception as e:
818
+ warn("run enrichment failed (%s) — record kept unenriched" % e)
819
+ return rec
820
+
821
+ # --- miss enrichment (misses only; SCHEMA.md §5.5) ------------------------
822
+ # Two lookups, both against runs.jsonl, both replacing an agent judgement with a
823
+ # fact the emitter can check:
824
+ #
825
+ # `miss` origin_run_id -> origin_model / origin_harness, and
826
+ # origin_confidence DERIVED (linked | inferred | unknown).
827
+ # An agent cannot know which model ran a phase two days ago, and a
828
+ # copied literal would corrupt every per-model comparison — the same
829
+ # reasoning that makes `harness` detected rather than declared.
830
+ #
831
+ # `miss-fix` fix_run_id -> that run's already-enriched token window, copied
832
+ # verbatim, plus cost_attribution DERIVED from its reqs_touched.
833
+ # Never recomputed and never estimated: if the run carried no
834
+ # window, this record carries no numbers (SCHEMA.md §5.5.3).
835
+ _RUNS_BY_START = None
836
+
837
+ def _fixes_on_run(fix_run_id, this_miss_id):
838
+ """How many DISTINCT misses this fix run has closed, counting the one being
839
+ written. The divisor for a repo with no REQs (see the miss-fix branch below).
840
+ Counts miss_ids, not records, so a re-attempt on the same miss never inflates
841
+ it. Returns 1 on any failure — the run measured a window and closed at least
842
+ this miss, so 1 is the floor, and a floor understates the share rather than
843
+ inventing one."""
844
+ n = {this_miss_id} if this_miss_id else set()
845
+ try:
846
+ with open(os.path.join(met_dir, "misses.jsonl"), encoding="utf-8") as fh:
847
+ for line in fh:
848
+ line = line.strip()
849
+ if not line:
850
+ continue
851
+ try:
852
+ r = json.loads(line)
853
+ except Exception:
854
+ continue
855
+ if r.get("kind") == "miss-fix" and r.get("fix_run_id") == fix_run_id \
856
+ and r.get("miss_id"):
857
+ n.add(r["miss_id"])
858
+ except Exception:
859
+ pass
860
+ return max(len(n), 1)
861
+
862
+
863
+ def _runs_by_start():
864
+ global _RUNS_BY_START
865
+ if _RUNS_BY_START is None:
866
+ _RUNS_BY_START = {}
867
+ try:
868
+ with open(os.path.join(met_dir, "runs.jsonl"), encoding="utf-8") as fh:
869
+ for line in fh:
870
+ line = line.strip()
871
+ if not line:
872
+ continue
873
+ try:
874
+ r = json.loads(line)
875
+ except Exception:
876
+ continue
877
+ if r.get("kind") == "run" and r.get("started"):
878
+ _RUNS_BY_START[r["started"]] = r
879
+ except Exception:
880
+ pass
881
+ return _RUNS_BY_START
882
+
883
+ _COST_FIELDS = ("tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write",
884
+ "cost_usd", "tokens_scope", "model")
885
+
886
+ # Kept identical to the --amend branch above. Two doors, ONE enforcement: an
887
+ # agent that hand-writes a miss-amend record onto the stream faces the same
888
+ # checks as one that calls --amend, because the invariant belongs to the
889
+ # emitter and not to whichever path reached it (SCHEMA.md §5.5.7).
890
+ _AMENDABLE = {
891
+ "why_missed": ("missing-checklist-item", "insufficient-verify-method",
892
+ "code-audit-limitation", "ambiguous-acceptance",
893
+ "dependency-not-declared", "instruction-ignored", "other"),
894
+ }
895
+
896
+ def _amend_ok(rec):
897
+ """True when this miss-amend may be appended. Returning False DROPS it —
898
+ an invalid amend writes nothing rather than landing as a record the reader
899
+ then has to defend itself against."""
900
+ 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:
902
+ warn("amend refused — %r/%r outside the §5.5.7 allowlist" % (fld, val))
903
+ return False
904
+ parent, current = None, None
905
+ try:
906
+ with open(os.path.join(met_dir, "misses.jsonl"), encoding="utf-8") as fh:
907
+ for line in fh:
908
+ line = line.strip()
909
+ if not line:
910
+ continue
911
+ try:
912
+ r = json.loads(line)
913
+ except Exception:
914
+ continue
915
+ if r.get("kind") == "miss" and r.get("miss_id") == mid:
916
+ parent, current = r, r.get(fld)
917
+ elif (r.get("kind") == "miss-amend" and r.get("miss_id") == mid
918
+ and r.get("field") == fld):
919
+ current = r.get("value")
920
+ except FileNotFoundError:
921
+ pass
922
+ if parent is None:
923
+ warn("amend refused — no miss record %s on this stream" % mid)
924
+ return False
925
+ if current is not None:
926
+ warn("amend refused — %s already set on %s" % (fld, mid))
927
+ return False
928
+ return True
929
+
930
+ # The closed vocabularies of SCHEMA.md §5.5.1 / §5.5.2 / §3.3, enforced ON WRITE.
931
+ #
932
+ # Until 2026-08-31 the emitter validated the miss-amend allowlist meticulously and
933
+ # validated a `miss` record's own enums NOT AT ALL — so a typo in miss_class landed
934
+ # permanently on an append-only stream, invented a bucket in every distribution built
935
+ # on it, and could not be corrected: `miss_class` is not amendable (§5.5.7 allows only
936
+ # closed-vocabulary JUDGEMENTS the emitter does not derive), and constraint 5 forbids
937
+ # editing the file. The only remaining move was to report it.
938
+ #
939
+ # That is the asymmetry this closes. Dropping an invalid record is the SAME choice
940
+ # _amend_ok() already makes and for the same reason: a record the reader has to
941
+ # defend itself against is worse than no record. The refusal is printed on stdout —
942
+ # an agent that believes it logged a miss and did not is worse off than one told no.
943
+ _MISS_ENUMS = {
944
+ "miss_class": ("missed-requirement", "partial-implementation", "wrong-behaviour",
945
+ "regression", "unspecified-gap", "spec-contradiction", "scope-creep",
946
+ "hallucinated-api", "standards-violation", "other"),
947
+ "artifact": ("brd", "architecture", "uidesign", "checklist", "devguide", "src",
948
+ "tests", "config", "other"),
949
+ "severity": ("blocker", "major", "minor"),
950
+ "found_by": ("gate", "self-smoke", "owner", "production", "agent-review",
951
+ "library-feedback"),
952
+ "why_missed": ("missing-checklist-item", "insufficient-verify-method",
953
+ "code-audit-limitation", "ambiguous-acceptance",
954
+ "dependency-not-declared", "instruction-ignored", "other"),
955
+ }
956
+ _FIX_ENUMS = {
957
+ "verdict_after": ("Verified", "Needs re-verify", "FAIL", "deferred", "wont-fix"),
958
+ "fix_cmd": ("fix-issues", "build-phase", "triage-issues", "amend-docs", "log-miss"),
959
+ }
960
+
961
+
962
+ def _enums_ok(rec, table):
963
+ """False -> DROP. Prints the refusal on stdout as --amend does; still exits 0,
964
+ because telemetry has no veto (§10) and neither does a refused record."""
965
+ for field, vocab in table.items():
966
+ val = rec.get(field)
967
+ if val is None: # optional and absent is always fine
968
+ continue
969
+ if val not in vocab:
970
+ sys.stdout.write(
971
+ "tf-emit: REFUSED — %r is not in the closed vocabulary for %s "
972
+ "(SCHEMA.md §5.5). Nothing was appended. Allowed: %s\n"
973
+ % (val, field, ", ".join(vocab)))
974
+ return False
975
+ return True
976
+
977
+
978
+ def enrich_miss(rec):
979
+ if stream != "misses":
980
+ return rec
981
+ if rec.get("kind") == "miss-amend":
982
+ return rec if _amend_ok(rec) else None
983
+ if rec.get("kind") == "miss" and not _enums_ok(rec, _MISS_ENUMS):
984
+ return None
985
+ if rec.get("kind") == "miss-fix" and not _enums_ok(rec, _FIX_ENUMS):
986
+ return None
987
+ try:
988
+ kind = rec.get("kind")
989
+ runs = _runs_by_start()
990
+ if kind == "miss":
991
+ src = runs.get(rec.get("origin_run_id")) if rec.get("origin_run_id") else None
992
+ if src:
993
+ rec["origin_confidence"] = "linked"
994
+ rec["origin_model"] = src.get("model")
995
+ rec["origin_harness"] = src.get("harness")
996
+ else:
997
+ # Named a phase but no run record backs it -> inferred. Named
998
+ # nothing -> unknown. Either way the model is FORCED to null,
999
+ # overwriting anything the caller put there: a guess here is worse
1000
+ # than a gap, because nothing downstream can see that it was one.
1001
+ rec["origin_confidence"] = "inferred" if rec.get("origin_phase") else "unknown"
1002
+ rec["origin_model"] = None
1003
+ rec["origin_harness"] = None
1004
+ elif kind == "miss-fix":
1005
+ src = runs.get(rec.get("fix_run_id")) if rec.get("fix_run_id") else None
1006
+ if src:
1007
+ for f in _COST_FIELDS:
1008
+ if f in src and f not in rec:
1009
+ rec[f] = src[f]
1010
+ if "cost_attribution" not in rec:
1011
+ touched = src.get("reqs_touched") or []
1012
+ if rec.get("tokens_scope") == "none" or not rec.get("tokens_scope"):
1013
+ rec["cost_attribution"] = "none"
1014
+ elif len(touched) == 1 and touched[0] == rec.get("req_id"):
1015
+ rec["cost_attribution"] = "sole"
1016
+ elif len(touched) > 1:
1017
+ rec["cost_attribution"] = "shared:%d" % len(touched)
1018
+ else:
1019
+ # reqs_touched is EMPTY but the run resolved and measured a
1020
+ # real window. Until 2026-08-28 this fell through to "none",
1021
+ # which §5.5.3 defines as "tokens_scope was none, or the
1022
+ # fix_run_id matched no run" — neither is true here, so the
1023
+ # stream was calling a measured window unattributable and
1024
+ # every cost figure for these repos was structurally zero.
1025
+ # A `framework` or `docs` project has no REQs to touch AT
1026
+ # ALL, so keying the divisor on reqs_touched can never work
1027
+ # there (the same blind spot as project_type:"framework",
1028
+ # fixed earlier the same day). The divisor is instead the
1029
+ # number of misses this run closed. That count is only
1030
+ # complete once the batch is on the stream, so what is
1031
+ # stored here is the count SO FAR and `tf-metrics.sh`
1032
+ # recomputes it per fix_run_id at report time (§8: derived
1033
+ # metrics are computed, never trusted from storage).
1034
+ _n = _fixes_on_run(rec.get("fix_run_id"), rec.get("miss_id"))
1035
+ rec["cost_attribution"] = "sole" if _n == 1 else "shared:%d" % _n
1036
+ else:
1037
+ rec.setdefault("cost_attribution", "none")
1038
+ rec.setdefault("tokens_scope", "none")
1039
+ except Exception as e:
1040
+ warn("miss enrichment failed (%s) — record kept unenriched" % e)
1041
+ return rec
1042
+
1043
+ # --- append --------------------------------------------------------------
1044
+ # newline="\n" is NOT optional. Python's text mode translates "\n" to the
1045
+ # platform separator, so on native Windows every append would land as CRLF —
1046
+ # mixing line endings inside an append-only log, and tripping Git's
1047
+ # "this file uses LF but will be checked out as CRLF" warning on a stream the
1048
+ # .gitattributes block pins to LF. These files are LF on every platform.
1049
+ lines = []
1050
+ for rec in records:
1051
+ out = enrich_miss(enrich_run(stamp_attempt(enrich(rec))))
1052
+ if out is None: # a refused miss-amend (§5.5.7) — dropped, never appended
1053
+ continue
1054
+ line = json.dumps(out, separators=(",", ":"), ensure_ascii=False)
1055
+ if "\n" in line or "\r" in line:
1056
+ warn("record serialised with a newline — record dropped")
1057
+ continue
1058
+ lines.append(line + "\n")
1059
+
1060
+ if not lines:
1061
+ raise SystemExit(0)
1062
+
1063
+ try:
1064
+ os.makedirs(met_dir, exist_ok=True)
1065
+ with open(os.path.join(met_dir, stream + ".jsonl"), "a",
1066
+ encoding="utf-8", newline="\n") as fh:
1067
+ fh.write("".join(lines))
1068
+ except Exception as e:
1069
+ warn("append failed (%s) — event dropped" % e)
1070
+ raise SystemExit(0)
1071
+ PY
1072
+ )"
1073
+ python3 -c "$TF_PROG" "$MET_DIR" "$STREAM" "$ROOT" 2>"$TF_ERR"
1074
+
1075
+ exit 0