@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.
- package/.claude/commands/TechieFlow/agents/analyst.md +97 -0
- package/.claude/commands/TechieFlow/agents/architect.md +84 -0
- package/.claude/commands/TechieFlow/agents/flow-master.md +217 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +72 -0
- package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +138 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +98 -0
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +78 -0
- package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +77 -0
- package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +117 -0
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +149 -0
- package/.claude/commands/TechieFlow/tasks/author-brd.md +79 -0
- package/.claude/commands/TechieFlow/tasks/build-phase.md +231 -0
- package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +278 -0
- package/.claude/commands/TechieFlow/tasks/create-doc.md +101 -0
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +507 -0
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +224 -0
- package/.claude/commands/TechieFlow/tasks/devguide.md +224 -0
- package/.claude/commands/TechieFlow/tasks/document-project.md +343 -0
- package/.claude/commands/TechieFlow/tasks/execute-checklist.md +86 -0
- package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +136 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +134 -0
- package/.claude/commands/TechieFlow/tasks/generate-html.md +115 -0
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +126 -0
- package/.claude/commands/TechieFlow/tasks/index-docs.md +173 -0
- package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +75 -0
- package/.claude/commands/TechieFlow/tasks/log-miss.md +160 -0
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +143 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +122 -0
- package/.claude/commands/TechieFlow/tasks/productguide.md +105 -0
- package/.claude/commands/TechieFlow/tasks/refresh-status.md +135 -0
- package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +117 -0
- package/.claude/commands/TechieFlow/tasks/shard-doc.md +185 -0
- package/.claude/commands/TechieFlow/tasks/split-brd.md +131 -0
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +157 -0
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +579 -0
- package/.claude/commands/generate-html.md +124 -0
- package/.codex/config.toml +46 -0
- package/.codex/hooks.json +63 -0
- package/.codex/rules/techieflow.rules +8 -0
- package/.opencode/command/generate-html.md +129 -0
- package/.opencode/plugin/techieflow.js +350 -0
- package/.tfcore/TOKEN-GUIDE.md +59 -0
- package/.tfcore/agents/analyst.md +97 -0
- package/.tfcore/agents/architect.md +84 -0
- package/.tfcore/agents/flow-master.md +217 -0
- package/.tfcore/agents/verifier.md +72 -0
- package/.tfcore/checklists/architect-checklist.md +438 -0
- package/.tfcore/core-config.yaml +47 -0
- package/.tfcore/data/brainstorming-techniques.md +36 -0
- package/.tfcore/data/elicitation-methods.md +154 -0
- package/.tfcore/data/techieflow-kb.md +807 -0
- package/.tfcore/data/technical-preferences.md +3 -0
- package/.tfcore/enhanced-ide-development-workflow.md +248 -0
- package/.tfcore/hooks/block-git.sh +301 -0
- package/.tfcore/hooks/codex-adapter.py +248 -0
- package/.tfcore/hooks/guard-artifacts.sh +101 -0
- package/.tfcore/hooks/guard-status-html.sh +102 -0
- package/.tfcore/hooks/guard-status.sh +124 -0
- package/.tfcore/hooks/guard-verify.sh +108 -0
- package/.tfcore/hooks/metrics-session.sh +129 -0
- package/.tfcore/hooks/session-pointer.sh +52 -0
- package/.tfcore/hooks/sweep-artifacts.sh +198 -0
- package/.tfcore/install-manifest.yaml +231 -0
- package/.tfcore/routing.yaml +105 -0
- package/.tfcore/tasks/_metrics-emit-gate.md +138 -0
- package/.tfcore/tasks/_smoke-test-policy.md +98 -0
- package/.tfcore/tasks/_status-update-gate.md +78 -0
- package/.tfcore/tasks/_yolo-mode.md +77 -0
- package/.tfcore/tasks/advanced-elicitation.md +117 -0
- package/.tfcore/tasks/amend-docs.md +149 -0
- package/.tfcore/tasks/author-brd.md +79 -0
- package/.tfcore/tasks/build-phase.md +231 -0
- package/.tfcore/tasks/create-deep-research-prompt.md +278 -0
- package/.tfcore/tasks/create-doc.md +101 -0
- package/.tfcore/tasks/day1-brownfield.md +507 -0
- package/.tfcore/tasks/day1-greenfield.md +224 -0
- package/.tfcore/tasks/devguide.md +224 -0
- package/.tfcore/tasks/document-project.md +343 -0
- package/.tfcore/tasks/execute-checklist.md +86 -0
- package/.tfcore/tasks/facilitate-brainstorming-session.md +136 -0
- package/.tfcore/tasks/fix-issues.md +134 -0
- package/.tfcore/tasks/generate-html.md +115 -0
- package/.tfcore/tasks/handoff-phase.md +126 -0
- package/.tfcore/tasks/index-docs.md +173 -0
- package/.tfcore/tasks/kb-mode-interaction.md +75 -0
- package/.tfcore/tasks/log-miss.md +160 -0
- package/.tfcore/tasks/metrics-report.md +143 -0
- package/.tfcore/tasks/mockups.md +122 -0
- package/.tfcore/tasks/productguide.md +105 -0
- package/.tfcore/tasks/refresh-status.md +135 -0
- package/.tfcore/tasks/render-workflow-docs.md +117 -0
- package/.tfcore/tasks/shard-doc.md +185 -0
- package/.tfcore/tasks/split-brd.md +131 -0
- package/.tfcore/tasks/triage-issues.md +157 -0
- package/.tfcore/tasks/verify-phase.md +579 -0
- package/.tfcore/telemetry/SCHEMA.md +678 -0
- package/.tfcore/telemetry/install-metrics.sh +390 -0
- package/.tfcore/telemetry/pre-commit +94 -0
- package/.tfcore/telemetry/tf-metrics.sh +1415 -0
- package/.tfcore/templates/architecture-tmpl.yaml +650 -0
- package/.tfcore/templates/brainstorming-output-tmpl.yaml +156 -0
- package/.tfcore/templates/brownfield-architecture-tmpl.yaml +476 -0
- package/.tfcore/templates/competitor-analysis-tmpl.yaml +306 -0
- package/.tfcore/templates/front-end-architecture-tmpl.yaml +218 -0
- package/.tfcore/templates/fullstack-architecture-tmpl.yaml +823 -0
- package/.tfcore/templates/market-research-tmpl.yaml +252 -0
- package/.tfcore/templates/project-brief-tmpl.yaml +221 -0
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +61 -0
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +127 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +204 -0
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +90 -0
- package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +33 -0
- package/.tfcore/templates/v4custom/app-devguide-tmpl.md +167 -0
- package/.tfcore/templates/v4custom/app-editorconfig-tmpl.editorconfig +44 -0
- package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +31 -0
- package/.tfcore/templates/v4custom/app-productguide-tmpl.md +54 -0
- package/.tfcore/templates/v4custom/app-project-status-tmpl.md +98 -0
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +48 -0
- package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +67 -0
- package/.tfcore/templates/v4custom/build-invocation-ladder.md +190 -0
- package/.tfcore/templates/v4custom/html-render-shell.md +502 -0
- package/.tfcore/templates/v4custom/metrics-report-template.md +273 -0
- package/.tfcore/templates/v4custom/opencode-operating-contract.md +56 -0
- package/.tfcore/user-guide.md +633 -0
- package/.tfcore/utils/techieflow-doc-template.md +325 -0
- package/.tfcore/utils/tf-assets.sh +310 -0
- package/.tfcore/utils/tf-codex-bind.py +196 -0
- package/.tfcore/utils/tf-codex-telemetry.py +60 -0
- package/.tfcore/utils/tf-emit.sh +1075 -0
- package/.tfcore/utils/tf-gitignore-audit.sh +281 -0
- package/.tfcore/utils/tf-goal.sh +421 -0
- package/.tfcore/utils/tf-harness.sh +160 -0
- package/.tfcore/utils/tf-mockup-parity.mjs +555 -0
- package/.tfcore/utils/tf-mockup-parity.sh +65 -0
- package/.tfcore/utils/tf-opencode-audit.py +126 -0
- package/.tfcore/utils/tf-perf.sh +241 -0
- package/.tfcore/utils/tf-render-html.py +715 -0
- package/.tfcore/utils/tf-render-html.sh +28 -0
- package/.tfcore/utils/tf-routing-bind.sh +216 -0
- package/.tfcore/utils/tf-routing.sh +247 -0
- package/.tfcore/utils/tf-yolo.sh +119 -0
- package/.tfcore/working-in-the-brownfield.md +606 -0
- package/LICENSE +201 -0
- package/README.md +1212 -0
- package/WORKFLOW.html +1986 -0
- package/docs/TechieFlow-Installation.md +299 -0
- package/opencode.jsonc +929 -0
- package/package.json +60 -0
- package/scripts/install.mjs +992 -0
- package/scripts/test-install.mjs +505 -0
- 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
|