@techierathore/techieflow 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +5 -13
  2. package/.claude/commands/TechieFlow/agents/architect.md +1 -16
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +42 -215
  4. package/.claude/commands/TechieFlow/agents/verifier.md +24 -64
  5. package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +24 -117
  6. package/.claude/commands/TechieFlow/tasks/_owner-language.md +26 -0
  7. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +20 -84
  8. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +10 -74
  9. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +20 -58
  10. package/.claude/commands/TechieFlow/tasks/amend-docs.md +21 -143
  11. package/.claude/commands/TechieFlow/tasks/build-phase.md +14 -224
  12. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
  13. package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
  14. package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
  15. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +31 -505
  16. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +28 -213
  17. package/.claude/commands/TechieFlow/tasks/deploy-checklist.md +19 -0
  18. package/.claude/commands/TechieFlow/tasks/devguide.md +10 -219
  19. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
  20. package/.claude/commands/TechieFlow/tasks/fix-issues.md +10 -129
  21. package/.claude/commands/TechieFlow/tasks/generate-html.md +5 -1
  22. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +18 -16
  23. package/.claude/commands/TechieFlow/tasks/log-miss.md +19 -158
  24. package/.claude/commands/TechieFlow/tasks/metrics-report.md +3 -1
  25. package/.claude/commands/TechieFlow/tasks/mockups.md +12 -117
  26. package/.claude/commands/TechieFlow/tasks/refresh-status.md +9 -130
  27. package/.claude/commands/TechieFlow/tasks/split-brd.md +10 -126
  28. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +14 -0
  29. package/.claude/commands/TechieFlow/tasks/triage-issues.md +11 -152
  30. package/.claude/commands/TechieFlow/tasks/verify-phase.md +36 -572
  31. package/.opencode/plugin/techieflow.js +40 -13
  32. package/.tfcore/agents/analyst.md +5 -13
  33. package/.tfcore/agents/architect.md +1 -16
  34. package/.tfcore/agents/flow-master.md +42 -215
  35. package/.tfcore/agents/verifier.md +24 -64
  36. package/.tfcore/core-config.yaml +10 -0
  37. package/.tfcore/hooks/block-git.sh +4 -4
  38. package/.tfcore/hooks/guard-artifacts.sh +2 -2
  39. package/.tfcore/hooks/guard-build.sh +76 -0
  40. package/.tfcore/hooks/guard-db.sh +90 -0
  41. package/.tfcore/hooks/guard-metrics.sh +68 -0
  42. package/.tfcore/hooks/guard-status-html.sh +116 -71
  43. package/.tfcore/hooks/guard-status.sh +43 -16
  44. package/.tfcore/hooks/guard-verify-deps.sh +124 -0
  45. package/.tfcore/hooks/guard-verify.sh +70 -48
  46. package/.tfcore/hooks/sweep-artifacts.sh +2 -2
  47. package/.tfcore/install-manifest.yaml +0 -18
  48. package/.tfcore/routing.yaml +86 -14
  49. package/.tfcore/standards/coding-standards-core.md +61 -0
  50. package/.tfcore/standards/coding-standards-dotnet.md +93 -0
  51. package/.tfcore/tasks/_metrics-emit-gate.md +24 -117
  52. package/.tfcore/tasks/_owner-language.md +26 -0
  53. package/.tfcore/tasks/_smoke-test-policy.md +20 -84
  54. package/.tfcore/tasks/_status-update-gate.md +10 -74
  55. package/.tfcore/tasks/_yolo-mode.md +20 -58
  56. package/.tfcore/tasks/amend-docs.md +21 -143
  57. package/.tfcore/tasks/build-phase.md +14 -224
  58. package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
  59. package/.tfcore/tasks/create-doc.md +4 -0
  60. package/.tfcore/tasks/create-project-brief.md +22 -0
  61. package/.tfcore/tasks/day1-brownfield.md +31 -505
  62. package/.tfcore/tasks/day1-greenfield.md +28 -213
  63. package/.tfcore/tasks/deploy-checklist.md +19 -0
  64. package/.tfcore/tasks/devguide.md +10 -219
  65. package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
  66. package/.tfcore/tasks/fix-issues.md +10 -129
  67. package/.tfcore/tasks/generate-html.md +5 -1
  68. package/.tfcore/tasks/handoff-phase.md +18 -16
  69. package/.tfcore/tasks/log-miss.md +19 -158
  70. package/.tfcore/tasks/metrics-report.md +3 -1
  71. package/.tfcore/tasks/mockups.md +12 -117
  72. package/.tfcore/tasks/refresh-status.md +9 -130
  73. package/.tfcore/tasks/split-brd.md +10 -126
  74. package/.tfcore/tasks/triage-and-fix.md +14 -0
  75. package/.tfcore/tasks/triage-issues.md +11 -152
  76. package/.tfcore/tasks/verify-phase.md +36 -572
  77. package/.tfcore/telemetry/SCHEMA.md +100 -8
  78. package/.tfcore/telemetry/tf-metrics.sh +564 -32
  79. package/.tfcore/templates/stack-defaults/dotnet.md +35 -0
  80. package/.tfcore/templates/stack-questions.md +77 -0
  81. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +3 -4
  82. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +107 -115
  83. package/.tfcore/templates/v4custom/app-brd-tmpl.md +108 -184
  84. package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
  85. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +48 -73
  86. package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +1 -1
  87. package/.tfcore/templates/v4custom/app-coding-standards-tmpl.md +52 -0
  88. package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
  89. package/.tfcore/templates/v4custom/app-deployment-checklist-tmpl.md +86 -0
  90. package/.tfcore/templates/v4custom/app-devguide-tmpl.md +69 -146
  91. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
  92. package/.tfcore/templates/v4custom/app-phases-tmpl.md +35 -0
  93. package/.tfcore/templates/v4custom/app-productguide-tmpl.md +38 -35
  94. package/.tfcore/templates/v4custom/app-project-status-tmpl.md +63 -73
  95. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +62 -37
  96. package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +69 -54
  97. package/.tfcore/templates/v4custom/build-invocation-ladder.md +17 -180
  98. package/.tfcore/templates/v4custom/build-subagent-prompt.md +17 -0
  99. package/.tfcore/templates/v4custom/html-render-shell.md +1 -1
  100. package/.tfcore/templates/v4custom/metrics-report-template.md +2 -2
  101. package/.tfcore/user-guide.md +0 -23
  102. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  103. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  104. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  105. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  106. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  107. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  108. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  109. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  110. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  111. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  112. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  113. package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
  114. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  115. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  116. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  117. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  118. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  119. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  120. package/.tfcore/utils/tf-brd-status.py +176 -0
  121. package/.tfcore/utils/tf-brd-status.sh +19 -0
  122. package/.tfcore/utils/tf-build-list.py +211 -0
  123. package/.tfcore/utils/tf-build-list.sh +21 -0
  124. package/.tfcore/utils/tf-build.sh +143 -0
  125. package/.tfcore/utils/tf-cdp-relay.ps1 +64 -0
  126. package/.tfcore/utils/tf-checklist-edit.py +133 -0
  127. package/.tfcore/utils/tf-day1-files.py +208 -0
  128. package/.tfcore/utils/tf-day1-files.sh +20 -0
  129. package/.tfcore/utils/tf-devguide-list.py +249 -0
  130. package/.tfcore/utils/tf-devguide-list.sh +18 -0
  131. package/.tfcore/utils/tf-doc-check.py +1369 -0
  132. package/.tfcore/utils/tf-doc-check.sh +29 -0
  133. package/.tfcore/utils/tf-emit.sh +545 -18
  134. package/.tfcore/utils/tf-fix-close.sh +115 -0
  135. package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
  136. package/.tfcore/utils/tf-goal.sh +284 -66
  137. package/.tfcore/utils/tf-harness.sh +4 -9
  138. package/.tfcore/utils/tf-log-miss.py +197 -0
  139. package/.tfcore/utils/tf-log-miss.sh +14 -0
  140. package/.tfcore/utils/tf-misses-md.py +208 -0
  141. package/.tfcore/utils/tf-misses-md.sh +14 -0
  142. package/.tfcore/utils/tf-mockups-locate.py +66 -0
  143. package/.tfcore/utils/tf-mockups-locate.sh +18 -0
  144. package/.tfcore/utils/tf-model-pick.sh +475 -0
  145. package/.tfcore/utils/tf-opencode-audit.py +23 -7
  146. package/.tfcore/utils/tf-perf-grade.py +97 -0
  147. package/.tfcore/utils/tf-perf-grade.sh +12 -0
  148. package/.tfcore/utils/tf-phase.sh +92 -0
  149. package/.tfcore/utils/tf-render-html.py +20 -0
  150. package/.tfcore/utils/tf-routing-bind.sh +97 -6
  151. package/.tfcore/utils/tf-routing.sh +13 -7
  152. package/.tfcore/utils/tf-selfcheck.sh +323 -0
  153. package/.tfcore/utils/tf-split-brd.py +309 -0
  154. package/.tfcore/utils/tf-split-brd.sh +22 -0
  155. package/.tfcore/utils/tf-status-evidence.sh +68 -0
  156. package/.tfcore/utils/tf-status-facts.py +358 -0
  157. package/.tfcore/utils/tf-status-facts.sh +20 -0
  158. package/.tfcore/utils/tf-triage.py +243 -0
  159. package/.tfcore/utils/tf-triage.sh +16 -0
  160. package/.tfcore/utils/tf-verify-boot.sh +250 -0
  161. package/.tfcore/utils/tf-verify-emit.sh +110 -0
  162. package/.tfcore/utils/tf-verify-env.sh +88 -0
  163. package/.tfcore/utils/tf-verify-list.py +270 -0
  164. package/.tfcore/utils/tf-verify-list.sh +13 -0
  165. package/.tfcore/utils/tf-verify-screens.mjs +355 -0
  166. package/.tfcore/utils/tf-verify-screens.sh +31 -0
  167. package/.tfcore/utils/tf-verify-tests.sh +166 -0
  168. package/.tfcore/utils/tf-verify-verdict.py +292 -0
  169. package/.tfcore/utils/tf-verify-verdict.sh +15 -0
  170. package/.tfcore/utils/tf-yolo.sh +59 -0
  171. package/README.md +120 -1145
  172. package/docs/TechieFlow-Installation.md +1 -5
  173. package/opencode.jsonc +37 -33
  174. package/package.json +1 -5
  175. package/scripts/install.mjs +56 -46
  176. package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +0 -117
  177. package/.claude/commands/TechieFlow/tasks/author-brd.md +0 -79
  178. package/.claude/commands/TechieFlow/tasks/document-project.md +0 -343
  179. package/.claude/commands/TechieFlow/tasks/execute-checklist.md +0 -86
  180. package/.claude/commands/TechieFlow/tasks/index-docs.md +0 -173
  181. package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +0 -75
  182. package/.claude/commands/TechieFlow/tasks/shard-doc.md +0 -185
  183. package/.codex/config.toml +0 -46
  184. package/.codex/hooks.json +0 -63
  185. package/.codex/rules/techieflow.rules +0 -8
  186. package/.tfcore/hooks/codex-adapter.py +0 -248
  187. package/.tfcore/tasks/advanced-elicitation.md +0 -117
  188. package/.tfcore/tasks/author-brd.md +0 -79
  189. package/.tfcore/tasks/document-project.md +0 -343
  190. package/.tfcore/tasks/execute-checklist.md +0 -86
  191. package/.tfcore/tasks/index-docs.md +0 -173
  192. package/.tfcore/tasks/kb-mode-interaction.md +0 -75
  193. package/.tfcore/tasks/shard-doc.md +0 -185
  194. package/.tfcore/templates/architecture-tmpl.yaml +0 -650
  195. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
  196. package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
  197. package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
  198. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
  199. package/.tfcore/templates/market-research-tmpl.yaml +0 -252
  200. package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
  201. package/.tfcore/utils/techieflow-doc-template.md +0 -325
  202. package/.tfcore/utils/tf-codex-bind.py +0 -196
  203. package/.tfcore/utils/tf-codex-telemetry.py +0 -60
  204. package/WORKFLOW.html +0 -1986
@@ -0,0 +1,475 @@
1
+ #!/usr/bin/env bash
2
+ # tf-model-pick.sh — which model should this tier run on RIGHT NOW, and how is it paid for.
3
+ #
4
+ # Two questions the framework could not answer before 2026-09-10, both found by the owner
5
+ # (MISS-TechieFlow-20260910-01 and -02):
6
+ #
7
+ # 1. The tier model's usage limit is reached. What now? -> `pick` walks the tier's
8
+ # `fallbacks:` chain and returns the first model that is not on cooldown. tf-goal.sh puts
9
+ # the limited model on cooldown until its stated reset; tf-routing-bind.sh asks the same
10
+ # question when it writes the harness bindings, so the sub-agents move with the main agent.
11
+ #
12
+ # 2. What does a cost number from this model MEAN? -> `billing` says whether the model is
13
+ # paid for by a flat-fee subscription (marginal dollars are zero, count tokens), by a
14
+ # metered API key (the dollars are real money), or by nothing at all because it runs on
15
+ # this machine. tf-emit.sh stamps the answer on every run record so a report can never sum
16
+ # a subscription's zero into a money figure.
17
+ #
18
+ # USAGE
19
+ # tf-model-pick.sh pick <tier> [harness] -> the model id to use now ("inherit" if none)
20
+ # tf-model-pick.sh chain <tier> [harness] -> the whole ordered chain, one per line,
21
+ # each marked ok | cooldown until <time>
22
+ # tf-model-pick.sh cooldown <harness> <model> <until> ["<why>"]
23
+ # -> park a model. <until> is an epoch, or +<n>
24
+ # minutes, or "probe" for the no-reset-time
25
+ # case (parked for TF_MODEL_PROBE_MIN, 60m).
26
+ # tf-model-pick.sh clear [harness] [model] -> unpark; no arguments clears everything
27
+ # tf-model-pick.sh status -> what every tier resolves to, and why
28
+ # tf-model-pick.sh billing <model> [harness] -> mode<TAB>source<TAB>detail
29
+ # tf-model-pick.sh rate <model> [harness] -> input<TAB>output<TAB>cache_read<TAB>cache_write
30
+ # <TAB>source, in US$ per million tokens; exit 1
31
+ # with no output when nothing prices the model
32
+ # tf-model-pick.sh was-limited <harness> <model> <epoch>
33
+ # -> exit 0 if that model was on cooldown then
34
+ #
35
+ # WHERE THE COOLDOWN LIVES. Inside the project, at `.tfcore/.session/model-cooldown.json`, because
36
+ # that is where TechieFlow itself lives: it is installed per project through npm/npx and never at
37
+ # machine level, and the owner has to be able to change it for one project without touching
38
+ # another (owner, 2026-09-10). `cooldown_file:` in routing.yaml moves it — an absolute path, or a
39
+ # path relative to the project root — and TF_MODEL_HEALTH_FILE overrides both for a single run.
40
+ #
41
+ # The cost of keeping it per project, said plainly: a usage limit belongs to an account, so two
42
+ # projects each discover the same limit for themselves, one wasted cycle apiece. Point them at one
43
+ # shared `cooldown_file:` if you would rather they learned it from each other.
44
+ #
45
+ # Expired entries are kept for 30 days so tf-emit.sh can answer "was this run a fallback?" after
46
+ # the fact, then pruned.
47
+ #
48
+ # Fail-soft everywhere: an unreadable file, a missing catalog, a provider nobody recognises — the
49
+ # answer degrades to the tier model and to billing mode "unknown". Routing is declared and
50
+ # observed, never enforced; this script keeps that property.
51
+
52
+ set +e
53
+ SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
54
+ ROOT="$(bash "$SELF_DIR/tf-harness.sh" root)"
55
+ command -v python3 >/dev/null 2>&1 || { echo "tf-model-pick: python3 required" >&2; exit 0; }
56
+
57
+ TF_MP_ROOT="$ROOT" python3 - "$@" <<'PY'
58
+ import json, os, sys, time, datetime
59
+
60
+ root = os.environ.get("TF_MP_ROOT") or os.getcwd()
61
+ argv = sys.argv[1:]
62
+ cmd = argv[0] if argv else "status"
63
+
64
+ KEEP_DAYS = 30
65
+ PROBE_MIN = int(os.environ.get("TF_MODEL_PROBE_MIN") or 60)
66
+ LOCAL_PROVIDERS = ("lmstudio", "ollama", "llamacpp", "llama-cpp", "vllm", "localhost", "local")
67
+ # A flat monthly fee whose usage is metered IN DOLLARS against a per-model allowance. OpenCode Go
68
+ # is $10/month with a $15–$60 monthly limit per model and 5-hour / weekly / monthly thresholds
69
+ # (https://opencode.ai/docs/go), so the dollars it reports are ALLOWANCE CONSUMED, not an invoice
70
+ # — and they are the very numbers that produce the limit the fallback chain exists for. An API
71
+ # key alone cannot tell you this; the provider name can, and `billing:` overrides it either way.
72
+ PLAN_PROVIDERS = ("opencode-go",)
73
+
74
+
75
+ def die(msg, code=2):
76
+ sys.stderr.write("tf-model-pick: %s\n" % msg)
77
+ raise SystemExit(code)
78
+
79
+
80
+ def now():
81
+ return int(time.time())
82
+
83
+
84
+ def iso(ep):
85
+ try:
86
+ return datetime.datetime.fromtimestamp(
87
+ int(ep), datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
88
+ except Exception:
89
+ return str(ep)
90
+
91
+
92
+ # --- routing.yaml (the same flat two-space format every other reader uses) --
93
+ def routing():
94
+ cfg = {"enabled": False, "tiers": {}, "fallbacks": {}, "billing": {}, "phases": {},
95
+ "cooldown_file": ""}
96
+ sect = tier = None
97
+ try:
98
+ for line in open(os.path.join(root, ".tfcore", "routing.yaml"), encoding="utf-8"):
99
+ line = line.rstrip("\n")
100
+ if not line.strip() or line.lstrip().startswith("#"):
101
+ continue
102
+ if not line.startswith(" "):
103
+ key, _, val = line.partition(":")
104
+ sect, tier = key.strip(), None
105
+ if sect == "enabled":
106
+ cfg["enabled"] = val.strip() == "true"
107
+ elif sect == "cooldown_file":
108
+ cfg["cooldown_file"] = val.strip()
109
+ elif sect in ("tiers", "fallbacks"):
110
+ if line.rstrip().endswith(":") and not line.startswith(" "):
111
+ tier = line.strip()[:-1]
112
+ cfg[sect].setdefault(tier, {})
113
+ elif tier and line.startswith(" "):
114
+ k, _, v = line.strip().partition(":")
115
+ cfg[sect][tier][k.strip()] = v.strip()
116
+ elif sect in ("billing", "phases"):
117
+ k, _, v = line.strip().partition(":")
118
+ cfg[sect][k.strip()] = v.strip()
119
+ except Exception:
120
+ pass
121
+ return cfg
122
+
123
+
124
+ CFG = routing()
125
+
126
+
127
+ def health_file():
128
+ """Project first: TechieFlow is installed per project, so its state lives per project too."""
129
+ env = os.environ.get("TF_MODEL_HEALTH_FILE")
130
+ if env:
131
+ return env
132
+ declared = CFG.get("cooldown_file") or ""
133
+ if declared:
134
+ return declared if os.path.isabs(declared) else os.path.join(root, declared)
135
+ return os.path.join(root, ".tfcore", ".session", "model-cooldown.json")
136
+
137
+
138
+ HEALTH = health_file()
139
+
140
+
141
+ def hkey(harness):
142
+ """routing.yaml writes the harness column as claude|opencode; callers may say claude-code."""
143
+ return "opencode" if str(harness).startswith("opencode") else "claude"
144
+
145
+
146
+ def norm(model, harness):
147
+ """The key two names for the same model share.
148
+
149
+ routing.yaml writes Claude models as the aliases a person types — opus, sonnet, haiku — while
150
+ a transcript reports the full id it resolved to, `claude-sonnet-5`. Parking one and looking up
151
+ the other would silently never match, so the family word is the key on Claude. OpenCode ids
152
+ are already exact and are left alone."""
153
+ m = (model or "").strip().lower()
154
+ if hkey(harness) != "claude":
155
+ return m
156
+ for fam in ("opus", "sonnet", "haiku", "fable"):
157
+ if fam in m:
158
+ return fam
159
+ return m
160
+
161
+
162
+ def chain_for(tier, harness):
163
+ k = hkey(harness)
164
+ out = []
165
+ first = (CFG["tiers"].get(tier) or {}).get(k)
166
+ if first:
167
+ out.append(first)
168
+ for m in (CFG["fallbacks"].get(tier) or {}).get(k, "").split(","):
169
+ m = m.strip()
170
+ if m and m not in out:
171
+ out.append(m)
172
+ return out
173
+
174
+
175
+ # --- the cooldown file -----------------------------------------------------
176
+ def load():
177
+ try:
178
+ with open(HEALTH, encoding="utf-8") as fh:
179
+ d = json.load(fh)
180
+ if isinstance(d, dict) and isinstance(d.get("cooldown"), list):
181
+ return d
182
+ except Exception:
183
+ pass
184
+ return {"cooldown": []}
185
+
186
+
187
+ def save(d):
188
+ cut = now() - KEEP_DAYS * 86400
189
+ d["cooldown"] = [e for e in d["cooldown"] if int(e.get("until_epoch") or 0) > cut]
190
+ try:
191
+ os.makedirs(os.path.dirname(HEALTH), exist_ok=True)
192
+ tmp = HEALTH + ".tmp"
193
+ with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
194
+ json.dump(d, fh, indent=1)
195
+ os.replace(tmp, HEALTH)
196
+ return True
197
+ except Exception as e:
198
+ sys.stderr.write("tf-model-pick: could not write %s (%s) — cooldown not recorded\n" % (HEALTH, e))
199
+ return False
200
+
201
+
202
+ def parked(d, harness, model, at=None):
203
+ """The live cooldown entry for this model at time `at`, or None."""
204
+ at = now() if at is None else int(at)
205
+ k = hkey(harness)
206
+ want = norm(model, harness)
207
+ for e in d["cooldown"]:
208
+ if hkey(e.get("harness")) != k or norm(e.get("model"), e.get("harness")) != want:
209
+ continue
210
+ if int(e.get("since_epoch") or 0) <= at < int(e.get("until_epoch") or 0):
211
+ return e
212
+ return None
213
+
214
+
215
+ # --- billing mode ----------------------------------------------------------
216
+ def opencode_auth():
217
+ p = os.environ.get("OPENCODE_AUTH") or os.path.join(
218
+ os.path.expanduser("~"), ".local", "share", "opencode", "auth.json")
219
+ try:
220
+ with open(p, encoding="utf-8") as fh:
221
+ return json.load(fh)
222
+ except Exception:
223
+ return {}
224
+
225
+
226
+ def opencode_catalog():
227
+ p = os.environ.get("OPENCODE_MODELS") or os.path.join(
228
+ os.path.expanduser("~"), ".cache", "opencode", "models.json")
229
+ try:
230
+ with open(p, encoding="utf-8") as fh:
231
+ return json.load(fh)
232
+ except Exception:
233
+ return {}
234
+
235
+
236
+ def billing(model, harness):
237
+ """-> (mode, source, detail). Mode is subscription | metered | local | unknown."""
238
+ if hkey(harness) == "claude":
239
+ declared = (CFG["billing"].get("claude") or "subscription").strip()
240
+ if declared == "auto":
241
+ if os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN"):
242
+ return ("metered", "env", "ANTHROPIC_API_KEY is set")
243
+ return ("subscription", "auto", "no API key in the environment")
244
+ if declared in ("subscription", "metered", "local"):
245
+ return (declared, "routing.yaml", "billing.claude")
246
+ return ("unknown", "routing.yaml", "billing.claude is %r" % declared)
247
+
248
+ declared = (CFG["billing"].get("opencode") or "auto").strip()
249
+ if declared in ("subscription", "metered", "local"):
250
+ return (declared, "routing.yaml", "billing.opencode")
251
+ provider = (model or "").split("/")[0]
252
+ if not provider:
253
+ return ("unknown", "none", "no model id")
254
+ # A named provider in `billing:` beats every guess below. This is the escape hatch for a
255
+ # local runtime called something the framework has never heard of, and for a key whose
256
+ # billing the owner knows and OpenCode's auth file does not say.
257
+ override = (CFG["billing"].get(provider) or "").strip()
258
+ if override in ("subscription", "plan", "metered", "local"):
259
+ return (override, "routing.yaml", "billing.%s" % provider)
260
+ if provider in PLAN_PROVIDERS:
261
+ return ("plan", "known-plan", "%s is a monthly plan with a per-model allowance" % provider)
262
+ auth = opencode_auth().get(provider)
263
+ if isinstance(auth, dict):
264
+ t = (auth.get("type") or "").lower()
265
+ if t == "oauth":
266
+ return ("subscription", "opencode-auth", "%s signed in with OAuth" % provider)
267
+ if t in ("api", "apikey", "api_key", "wellknown"):
268
+ return ("metered", "opencode-auth", "%s uses an API key" % provider)
269
+ return ("unknown", "opencode-auth", "%s auth type %r" % (provider, t))
270
+ if provider.lower().startswith(LOCAL_PROVIDERS):
271
+ return ("local", "provider-name", "%s runs on this machine" % provider)
272
+ cat = opencode_catalog().get(provider)
273
+ if isinstance(cat, dict):
274
+ # In the catalog but not signed in: it is a hosted, billable provider this machine has
275
+ # simply not authenticated. Saying "metered" would invent a bill; say so instead.
276
+ return ("unknown", "models.dev", "%s is a hosted provider with no credential here" % provider)
277
+ return ("unknown", "none", "provider %s is in neither auth.json nor the catalog" % provider)
278
+
279
+
280
+ # --- the rate card ---------------------------------------------------------
281
+ # What a model would cost at list price, per million tokens. This is a PRICE LIST, not a bill: it
282
+ # is what makes a subscription run comparable with a metered one (owner, 2026-09-10), and it is
283
+ # kept in its own field so that nothing ever overwrites a figure a provider actually measured.
284
+ #
285
+ # Two sources, project first:
286
+ # .tfcore/rate-card.json {"<model id>": {"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}}
287
+ # for a negotiated rate, or a model the catalog has never heard of.
288
+ # models.dev the catalog OpenCode already keeps at ~/.cache/opencode/models.json.
289
+ #
290
+ # Two simplifications, stated rather than hidden: the BASE rate is used, so a provider's
291
+ # long-context surcharge (`context_over_200k`) is not applied — nothing records per-message
292
+ # context length, and guessing it would be worse than the small understatement; and a rate the
293
+ # catalog does not carry produces no number at all rather than a zero.
294
+ def project_rate_card():
295
+ try:
296
+ with open(os.path.join(root, ".tfcore", "rate-card.json"), encoding="utf-8") as fh:
297
+ d = json.load(fh)
298
+ return d if isinstance(d, dict) else {}
299
+ except Exception:
300
+ return {}
301
+
302
+
303
+ def rate(model, harness):
304
+ """-> (input, output, cache_read, cache_write, source) per 1M tokens, or None."""
305
+ m = (model or "").strip()
306
+ if not m or m.startswith("<"): # "<synthetic>" and friends are not models
307
+ return None
308
+ own = project_rate_card()
309
+ for key in (m, m.split("/")[-1]):
310
+ r = own.get(key)
311
+ if isinstance(r, dict):
312
+ return (float(r.get("input") or 0), float(r.get("output") or 0),
313
+ float(r.get("cache_read") or 0), float(r.get("cache_write") or 0),
314
+ ".tfcore/rate-card.json")
315
+ cat = opencode_catalog()
316
+ provider, _, bare = m.partition("/")
317
+ entries = []
318
+ if bare and provider in cat:
319
+ entries = [(provider, cat[provider].get("models", {}).get(bare))]
320
+ else:
321
+ # A bare id — every Claude Code model is one, and an older OpenCode record can be. Look
322
+ # in anthropic first, because that is what a Claude transcript reports; then in the
323
+ # providers this machine is actually signed in to, because those are the ones that could
324
+ # have served it; then everything else alphabetically, so the same id always prices the
325
+ # same way. Two providers list `gpt-5.6-sol` at different rates, and picking the one you
326
+ # are signed in to is the difference between $4 and $5 per million input tokens.
327
+ signed_in = [k for k in sorted(opencode_auth()) if k in cat and k != "anthropic"]
328
+ rest = sorted(k for k in cat if k != "anthropic" and k not in signed_in)
329
+ order = (["anthropic"] if "anthropic" in cat else []) + signed_in + rest
330
+ entries = [(p, cat[p].get("models", {}).get(m)) for p in order]
331
+ for provider, entry in entries:
332
+ if not isinstance(entry, dict):
333
+ continue
334
+ c = entry.get("cost")
335
+ if not isinstance(c, dict):
336
+ continue
337
+ return (float(c.get("input") or 0), float(c.get("output") or 0),
338
+ float(c.get("cache_read") or 0), float(c.get("cache_write") or 0),
339
+ "models.dev/" + provider)
340
+ return None
341
+
342
+
343
+ # --- commands --------------------------------------------------------------
344
+ if cmd == "pick":
345
+ if len(argv) < 2:
346
+ die("usage: tf-model-pick.sh pick <tier> [harness]")
347
+ tier, harness = argv[1], (argv[2] if len(argv) > 2 else "claude")
348
+ d = load()
349
+ for m in chain_for(tier, harness):
350
+ if not parked(d, harness, m):
351
+ print(m)
352
+ raise SystemExit(0)
353
+ print("inherit") # every model in the chain is limited — the caller waits
354
+ raise SystemExit(0)
355
+
356
+ if cmd == "chain":
357
+ if len(argv) < 2:
358
+ die("usage: tf-model-pick.sh chain <tier> [harness]")
359
+ tier, harness = argv[1], (argv[2] if len(argv) > 2 else "claude")
360
+ d = load()
361
+ ch = chain_for(tier, harness)
362
+ if not ch:
363
+ print("(no model declared for tier %s on %s)" % (tier, harness))
364
+ raise SystemExit(0)
365
+ for i, m in enumerate(ch):
366
+ e = parked(d, harness, m)
367
+ mark = "cooldown until %s (%s)" % (iso(e["until_epoch"]), e.get("why") or "limited") if e else "ok"
368
+ print("%d\t%s\t%s%s" % (i + 1, m, mark, "" if i else " <- tier model"))
369
+ raise SystemExit(0)
370
+
371
+ if cmd == "cooldown":
372
+ if len(argv) < 4:
373
+ die("usage: tf-model-pick.sh cooldown <harness> <model> <epoch|+minutes|probe> [\"why\"]")
374
+ harness, model, until = argv[1], argv[2], argv[3]
375
+ why = argv[4] if len(argv) > 4 else "usage limit"
376
+ if until == "probe":
377
+ until_ep = now() + PROBE_MIN * 60
378
+ elif until.startswith("+"):
379
+ until_ep = now() + int(float(until[1:])) * 60
380
+ else:
381
+ try:
382
+ until_ep = int(float(until))
383
+ except Exception:
384
+ die("<until> must be an epoch, +<minutes> or probe")
385
+ if until_ep <= now():
386
+ until_ep = now() + 60
387
+ d = load()
388
+ d["cooldown"] = [e for e in d["cooldown"]
389
+ if not (hkey(e.get("harness")) == hkey(harness)
390
+ and norm(e.get("model"), e.get("harness")) == norm(model, harness)
391
+ and int(e.get("until_epoch") or 0) > now())]
392
+ d["cooldown"].append({"harness": hkey(harness), "model": model,
393
+ "since_epoch": now(), "since": iso(now()),
394
+ "until_epoch": until_ep, "until": iso(until_ep),
395
+ "why": why, "app": os.path.basename(root)})
396
+ save(d)
397
+ print("cooldown: %s/%s until %s (%s)" % (hkey(harness), model, iso(until_ep), why))
398
+ raise SystemExit(0)
399
+
400
+ if cmd == "clear":
401
+ d = load()
402
+ before = len(d["cooldown"])
403
+ if len(argv) == 1:
404
+ d["cooldown"] = []
405
+ else:
406
+ harness = argv[1]
407
+ model = argv[2] if len(argv) > 2 else None
408
+ d["cooldown"] = [e for e in d["cooldown"]
409
+ if not (hkey(e.get("harness")) == hkey(harness)
410
+ and (model is None
411
+ or norm(e.get("model"), e.get("harness")) == norm(model, harness))
412
+ and int(e.get("until_epoch") or 0) > now())]
413
+ save(d)
414
+ print("cleared %d cooldown entr(y/ies)" % (before - len(d["cooldown"])))
415
+ raise SystemExit(0)
416
+
417
+ if cmd == "billing":
418
+ if len(argv) < 2:
419
+ die("usage: tf-model-pick.sh billing <model> [harness]")
420
+ model = argv[1]
421
+ harness = argv[2] if len(argv) > 2 else ("opencode" if "/" in model else "claude")
422
+ print("%s\t%s\t%s" % billing(model, harness))
423
+ raise SystemExit(0)
424
+
425
+ if cmd == "rate":
426
+ if len(argv) < 2:
427
+ die("usage: tf-model-pick.sh rate <model> [harness]")
428
+ model = argv[1]
429
+ harness = argv[2] if len(argv) > 2 else ("opencode" if "/" in model else "claude")
430
+ r = rate(model, harness)
431
+ if not r:
432
+ raise SystemExit(1)
433
+ print("%g\t%g\t%g\t%g\t%s" % r)
434
+ raise SystemExit(0)
435
+
436
+ if cmd == "was-limited":
437
+ if len(argv) < 4:
438
+ die("usage: tf-model-pick.sh was-limited <harness> <model> <epoch>")
439
+ raise SystemExit(0 if parked(load(), argv[1], argv[2], argv[3]) else 1)
440
+
441
+ if cmd == "status":
442
+ d = load()
443
+ live = [e for e in d["cooldown"] if int(e.get("until_epoch") or 0) > now()]
444
+ print("Cooldown file: %s" % HEALTH)
445
+ print("Routing: %s" % ("ON" if CFG["enabled"] else "OFF"))
446
+ print()
447
+ for harness in ("claude", "opencode"):
448
+ print("%s:" % harness)
449
+ for tier in ("frontier", "standard", "economy"):
450
+ ch = chain_for(tier, harness)
451
+ if not ch:
452
+ continue
453
+ use = next((m for m in ch if not parked(d, harness, m)), None)
454
+ mode = billing(use, harness)[0] if use else "-"
455
+ skipped = [m for m in ch if parked(d, harness, m)]
456
+ note = " (skipping %s)" % ", ".join(skipped) if skipped else ""
457
+ print(" %-9s -> %-34s [%s]%s" % (tier, use or "inherit (all limited — wait)", mode, note))
458
+ print()
459
+ if live:
460
+ print("On cooldown now:")
461
+ for e in sorted(live, key=lambda x: x["until_epoch"]):
462
+ print(" %-9s %-34s until %s (%s, from %s)"
463
+ % (e.get("harness"), e.get("model"), e.get("until"), e.get("why"), e.get("app")))
464
+ else:
465
+ print("On cooldown now: nothing.")
466
+ print()
467
+ print("Park a model by hand: bash .tfcore/utils/tf-model-pick.sh cooldown opencode <model> +180 \"monthly limit\"")
468
+ print("Unpark: bash .tfcore/utils/tf-model-pick.sh clear opencode <model>")
469
+ raise SystemExit(0)
470
+
471
+ die("unknown command %r — run with no arguments for the usage block" % cmd)
472
+ PY
473
+ rc=$?
474
+ if [[ $rc -eq 2 ]]; then sed -n '17,32p' "$0" | sed 's/^# \{0,1\}//' >&2; fi
475
+ exit $rc
@@ -99,18 +99,34 @@ def main():
99
99
  diags.append("DIAG|dead-ref|%s" % ref)
100
100
 
101
101
  # --- project-only content ---------------------------------------------
102
+ # A key counts as project content only if it could actually work. Two kinds
103
+ # cannot, and calling them project content is what kept a repo's root config
104
+ # frozen with every framework agent dead (MISS-TechieFlow-20260907-08):
105
+ # - the framework's own retired names: `techieflow:` and the `bmad`
106
+ # prefixes it carried before the rename;
107
+ # - any key whose every {file:} reference is missing from the app, so the
108
+ # block cannot load whatever it names. That rule needs no list of old
109
+ # prefixes and so survives the next rename.
110
+ dead_refs = set(d.split("|", 2)[2] for d in diags if d.startswith("DIAG|dead-ref|"))
111
+
112
+ def is_litter(key, block):
113
+ low = key.lower()
114
+ if low.startswith("techieflow:") or low.startswith("bmad:") or low.startswith("bmad-"):
115
+ return True
116
+ refs = re.findall(r"\{file:([^}]*)\}", json.dumps(block))
117
+ return bool(refs) and all(r in dead_refs for r in refs)
118
+
102
119
  project = []
103
120
  for k in app:
104
121
  if k not in tmpl:
105
122
  project.append(k)
106
- for k in (app.get("agent") or {}):
107
- if k not in (tmpl.get("agent") or {}):
108
- project.append("agent.%s" % k)
109
- for k in (app.get("command") or {}):
110
- if k in (tmpl.get("command") or {}):
123
+ for k, blk in (app.get("agent") or {}).items():
124
+ if k in (tmpl.get("agent") or {}) or is_litter(k, blk):
125
+ continue
126
+ project.append("agent.%s" % k)
127
+ for k, blk in (app.get("command") or {}).items():
128
+ if k in (tmpl.get("command") or {}) or is_litter(k, blk):
111
129
  continue
112
- if k.startswith("techieflow:"):
113
- continue # retired FRAMEWORK command — litter, not project content
114
130
  project.append("command.%s" % k)
115
131
 
116
132
  if app_raw == tmpl_raw:
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ """tf-perf-grade.py — grade a tf-perf.sh measurement against a row's budget (see tf-perf-grade.sh).
3
+
4
+ bash .tfcore/utils/tf-perf-grade.sh --budget "p95 ttfb <= 500ms @ concurrency 50" \
5
+ --json tests/.artifacts/verify/perf/REQ-NFR-001.json [--json-out <file>]
6
+
7
+ Prints one line: PERF-OK | PERF-MARGINAL (over the budget by up to a quarter: a warning, never a
8
+ demotion) | PERF-FAIL (slower than budget × 1.25, or the app shed a tenth or more of the requests
9
+ at that concurrency) | PERF-UNMEASURED with the reason (unreachable, auth wall, non-200 answers,
10
+ errors during the run, a Debug build, fewer than 20 samples). It reads the budget, never invents
11
+ one. Exit 0 OK or MARGINAL · 1 FAIL · 2 UNMEASURED · 3 usage.
12
+ """
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+
18
+ BUDGET = re.compile(r"(p50|p95|max)\s+(ttfb|load)\s*<=\s*(\d+)\s*ms(?:\s*@\s*concurrency\s+(\d+))?", re.I)
19
+
20
+
21
+ def grade(budget, data):
22
+ """-> dict(verdict, reason, metric, percentile, measured, budget, concurrency, failure_class)"""
23
+ m = BUDGET.search(budget or "")
24
+ if not m:
25
+ return {"verdict": "PERF-UNMEASURED", "reason": f"budget line not in the fixed form: {budget!r}"}
26
+ pct, metric, limit, conc = m.group(1).lower(), m.group(2).lower(), int(m.group(3)), int(m.group(4) or 1)
27
+ out = {"metric": metric, "percentile": pct, "budget": limit, "concurrency": conc, "measured": None, "failure_class": None}
28
+ st = data.get("status")
29
+ if st == "unreachable":
30
+ return dict(out, verdict="PERF-UNMEASURED", reason="the app was unreachable (a build check problem, not a speed result)")
31
+ if st == "redirected":
32
+ return dict(out, verdict="PERF-UNMEASURED", reason="auth wall: every request was redirected; present a session with --cookie or --header")
33
+ levels = data.get("levels") or []
34
+ if not levels:
35
+ return dict(out, verdict="PERF-UNMEASURED", reason="no measurement level in the file")
36
+ lvl = next((l for l in levels if l.get("concurrency") == conc), None)
37
+ note = ""
38
+ if lvl is None:
39
+ lvl = levels[0]
40
+ note = f" (measured at concurrency {lvl.get('concurrency')}, the budget names {conc})"
41
+ err_rate = lvl.get("error_rate") or 0
42
+ if err_rate >= 0.10:
43
+ surv = (lvl.get(f"{metric}_ms") or {}).get(pct)
44
+ return dict(out, verdict="PERF-FAIL", failure_class="timeout", measured=surv,
45
+ reason=f"{lvl.get('errors')} of {lvl.get('samples', 0) + lvl.get('errors', 0)} requests failed at concurrency {lvl.get('concurrency')}: the budget is not servable; survivors' {pct} {metric} {surv} ms")
46
+ if lvl.get("non_200"):
47
+ return dict(out, verdict="PERF-UNMEASURED", reason=f"non-200 answers ({', '.join(lvl['non_200'][:3])}); a wrong path or a correctness failure, not a speed result")
48
+ if (lvl.get("redirect_rate") or 0) > 0:
49
+ return dict(out, verdict="PERF-UNMEASURED", reason="some requests were redirected (auth wall); present a session and re-measure")
50
+ if err_rate > 0:
51
+ return dict(out, verdict="PERF-UNMEASURED", reason=f"errors during the run (rate {err_rate}); re-run when quiet")
52
+ if str(data.get("build_config", "unknown")) != "Release":
53
+ return dict(out, verdict="PERF-UNMEASURED", reason=f"build is {data.get('build_config', 'unknown')}, not Release; a Debug number is not evidence")
54
+ val = (lvl.get(f"{metric}_ms") or {}).get(pct)
55
+ if val is None:
56
+ return dict(out, verdict="PERF-UNMEASURED", reason=f"no {pct} {metric} figure in the file")
57
+ out["measured"] = val
58
+ if lvl.get("weak"):
59
+ return dict(out, verdict="PERF-UNMEASURED", reason=f"weak sample ({lvl.get('samples')} requests behind the {pct}); raise --requests and re-run")
60
+ if val <= limit:
61
+ return dict(out, verdict="PERF-OK", reason=f"{pct} {metric} {val} ms within {limit} ms @ concurrency {lvl.get('concurrency')}{note}")
62
+ if val <= limit * 1.25:
63
+ return dict(out, verdict="PERF-MARGINAL", reason=f"{pct} {metric} {val} ms vs budget {limit} ms @ concurrency {lvl.get('concurrency')} (marginal){note}")
64
+ return dict(out, verdict="PERF-FAIL", failure_class="slow-ttfb" if metric == "ttfb" else "slow-load",
65
+ reason=f"{pct} {metric} {val} ms vs budget {limit} ms @ concurrency {lvl.get('concurrency')}{note}")
66
+
67
+
68
+ def main(argv):
69
+ budget = jpath = out = None
70
+ i = 1
71
+ while i < len(argv):
72
+ if argv[i] == "--budget" and i + 1 < len(argv):
73
+ budget = argv[i + 1]; i += 2
74
+ elif argv[i] == "--json" and i + 1 < len(argv):
75
+ jpath = argv[i + 1]; i += 2
76
+ elif argv[i] == "--json-out" and i + 1 < len(argv):
77
+ out = argv[i + 1]; i += 2
78
+ else:
79
+ print(__doc__); return 3
80
+ if not budget or not jpath:
81
+ print(__doc__); return 3
82
+ if not os.path.isfile(jpath):
83
+ print(f"PERF-UNMEASURED reason=measurement file {jpath} does not exist"); return 2
84
+ try:
85
+ data = json.load(open(jpath, encoding="utf-8"))
86
+ except Exception as e:
87
+ print(f"PERF-UNMEASURED reason=measurement file unreadable: {e}"); return 2
88
+ g = grade(budget, data)
89
+ if out:
90
+ os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
91
+ json.dump(g, open(out, "w"), indent=1)
92
+ print(f"{g['verdict']} reason={g['reason']}")
93
+ return {"PERF-OK": 0, "PERF-MARGINAL": 0, "PERF-FAIL": 1}.get(g["verdict"], 2)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ sys.exit(main(sys.argv))
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+ # tf-perf-grade.sh — grade a tf-perf.sh measurement against a row's declared budget (Sitting 4c, 2026-09-06).
3
+ #
4
+ # bash .tfcore/utils/tf-perf-grade.sh --budget "p95 ttfb <= 500ms @ concurrency 50" \
5
+ # --json tests/.artifacts/verify/perf/REQ-NFR-001.json [--json-out <file>]
6
+ #
7
+ # Prints PERF-OK, PERF-MARGINAL, PERF-FAIL or PERF-UNMEASURED with the reason. Never invents a budget.
8
+ # Exit 0 OK or MARGINAL · 1 FAIL · 2 UNMEASURED · 3 usage. Details: tf-perf-grade.py.
9
+ set -u
10
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ command -v python3 >/dev/null 2>&1 || { echo "PERF-UNMEASURED reason=python3 is required"; exit 2; }
12
+ exec python3 "$HERE/tf-perf-grade.py" "$@"