@techierathore/techieflow 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +5 -8
  2. package/.claude/commands/TechieFlow/agents/architect.md +1 -10
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
  4. package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
  5. package/.claude/commands/TechieFlow/tasks/_owner-language.md +26 -0
  6. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
  7. package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
  8. package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
  9. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
  10. package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
  11. package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
  12. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +2 -2
  14. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
  15. package/.claude/commands/TechieFlow/tasks/generate-html.md +5 -1
  16. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +18 -16
  17. package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
  18. package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
  19. package/.opencode/plugin/techieflow.js +2 -2
  20. package/.tfcore/agents/analyst.md +5 -8
  21. package/.tfcore/agents/architect.md +1 -10
  22. package/.tfcore/agents/flow-master.md +1 -0
  23. package/.tfcore/agents/verifier.md +1 -0
  24. package/.tfcore/hooks/guard-metrics.sh +13 -1
  25. package/.tfcore/hooks/guard-verify-deps.sh +124 -0
  26. package/.tfcore/routing.yaml +61 -0
  27. package/.tfcore/tasks/_owner-language.md +26 -0
  28. package/.tfcore/tasks/_smoke-test-policy.md +1 -1
  29. package/.tfcore/tasks/amend-docs.md +6 -5
  30. package/.tfcore/tasks/build-phase.md +1 -1
  31. package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
  32. package/.tfcore/tasks/create-doc.md +4 -0
  33. package/.tfcore/tasks/create-project-brief.md +22 -0
  34. package/.tfcore/tasks/day1-brownfield.md +2 -2
  35. package/.tfcore/tasks/day1-greenfield.md +2 -2
  36. package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
  37. package/.tfcore/tasks/generate-html.md +5 -1
  38. package/.tfcore/tasks/handoff-phase.md +18 -16
  39. package/.tfcore/tasks/metrics-report.md +2 -0
  40. package/.tfcore/tasks/verify-phase.md +1 -1
  41. package/.tfcore/telemetry/SCHEMA.md +57 -1
  42. package/.tfcore/telemetry/tf-metrics.sh +416 -19
  43. package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
  44. package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
  45. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
  46. package/.tfcore/templates/v4custom/html-render-shell.md +1 -1
  47. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  48. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  49. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  50. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  51. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  52. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  53. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  54. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  55. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  56. package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
  57. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  58. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  59. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  60. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  61. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  62. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  63. package/.tfcore/utils/tf-build-list.py +23 -3
  64. package/.tfcore/utils/tf-build-list.sh +3 -2
  65. package/.tfcore/utils/tf-doc-check.py +106 -4
  66. package/.tfcore/utils/tf-emit.sh +353 -16
  67. package/.tfcore/utils/tf-fix-close.sh +34 -2
  68. package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
  69. package/.tfcore/utils/tf-goal.sh +93 -6
  70. package/.tfcore/utils/tf-misses-md.py +14 -17
  71. package/.tfcore/utils/tf-misses-md.sh +5 -4
  72. package/.tfcore/utils/tf-model-pick.sh +475 -0
  73. package/.tfcore/utils/tf-phase.sh +18 -0
  74. package/.tfcore/utils/tf-render-html.py +20 -0
  75. package/.tfcore/utils/tf-routing-bind.sh +87 -1
  76. package/.tfcore/utils/tf-routing.sh +7 -0
  77. package/.tfcore/utils/tf-selfcheck.sh +323 -0
  78. package/.tfcore/utils/tf-split-brd.py +6 -1
  79. package/.tfcore/utils/tf-status-facts.py +61 -12
  80. package/.tfcore/utils/tf-verify-screens.mjs +85 -13
  81. package/.tfcore/utils/tf-verify-screens.sh +12 -3
  82. package/.tfcore/utils/tf-verify-tests.sh +60 -17
  83. package/.tfcore/utils/tf-verify-verdict.py +11 -2
  84. package/README.md +15 -5
  85. package/opencode.jsonc +24 -0
  86. package/package.json +1 -1
  87. package/scripts/install.mjs +4 -0
  88. package/.tfcore/templates/architecture-tmpl.yaml +0 -650
  89. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
  90. package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
  91. package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
  92. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
  93. package/.tfcore/templates/market-research-tmpl.yaml +0 -252
  94. package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
  95. package/.tfcore/utils/techieflow-doc-template.md +0 -325
@@ -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
@@ -51,6 +51,24 @@ case "${1:-}" in
51
51
  ( cd "$ROOT" && bash .tfcore/utils/tf-doc-check.sh --root "$ROOT" --baseline-write "${docs[@]}" 2>/dev/null | tail -1 >&2 ) || true
52
52
  fi
53
53
  fi
54
+ # The framework's own scripts, against THIS project's real files, before the command
55
+ # does anything (2026-09-09). Nine framework defects reached TfLens by being met
56
+ # mid-build, and each cost a switch to the framework repo and back. Step 0 is the one
57
+ # place every command already passes through, so the check lives here and no task file
58
+ # has to mention it -- including commands added later.
59
+ #
60
+ # It NEVER blocks: a framework defect is reported and the command carries on, exactly
61
+ # as a library gap does. Set TF_SKIP_SELFCHECK=1 to skip it (the self-tests do).
62
+ if [[ "${TF_SKIP_SELFCHECK:-0}" != "1" && -f "$ROOT/.tfcore/utils/tf-selfcheck.sh" ]]; then
63
+ sc="$(cd "$ROOT" && bash .tfcore/utils/tf-selfcheck.sh 2>/dev/null)" || true
64
+ if grep -q '^FAIL' <<<"$sc"; then
65
+ echo "tf-phase: the framework is broken on this project — log these in the feedback file and carry on:" >&2
66
+ grep '^FAIL' <<<"$sc" >&2
67
+ echo "tf-phase: entries to paste: bash .tfcore/utils/tf-selfcheck.sh --report" >&2
68
+ else
69
+ echo "tf-phase: framework self-check clean" >&2
70
+ fi
71
+ fi
54
72
  ;;
55
73
  goal)
56
74
  # written by tf-goal.sh at the start of a run's first cycle; claimed by the first `start`
@@ -479,6 +479,22 @@ def is_requirements_checklist(base, raw):
479
479
  or "SINGLE SOURCE OF TRUTH" in head)
480
480
 
481
481
 
482
+ def is_miss_list(base, raw):
483
+ """Is this THE derived miss log, or an ordinary document about misses?
484
+
485
+ `docs/{AppName}-Misses.md` is rebuilt from `misses.jsonl` on every write and is
486
+ read by agents and by the owner in markdown. Rendering it produced an HTML copy
487
+ nobody opened, re-rendered on every miss, in every project — 20 of them across
488
+ the estate before this refusal existed (owner, 2026-09-08).
489
+
490
+ Identify the document, do not guess from the suffix, exactly as the checklist
491
+ rule does: the name must match AND the content must carry the generator's own
492
+ Source row. A hand-written note called `Release-Misses.md` has neither."""
493
+ if not re.search(r"-Misses\.md$", base, re.I):
494
+ return False
495
+ return "Rewritten by `tf-misses-md.sh`" in raw[:2000]
496
+
497
+
482
498
  NEXT_CMD_H2 = re.compile(r"^##\s+Next command to run\s*$", re.I)
483
499
 
484
500
 
@@ -541,6 +557,10 @@ def render(md_path, css, head_js, body_js, out_dir=None):
541
557
  raise Refused("%s is the requirements checklist — checklists are AI-agent "
542
558
  "working documents and are NEVER rendered to HTML "
543
559
  "(html-render-shell §0)." % base)
560
+ if is_miss_list(base, raw_probe):
561
+ raise Refused("%s is the derived miss log — it is read in markdown by agents "
562
+ "and by the owner, and is NEVER rendered to HTML "
563
+ "(html-render-shell §0)." % base)
544
564
 
545
565
  raw = raw_probe
546
566
  lines = raw.split("\n")
@@ -41,6 +41,63 @@ root = sys.argv[1]
41
41
  ryaml = os.path.join(root, ".tfcore", "routing.yaml")
42
42
  manifest_path = os.path.join(root, ".tfcore", ".session", "routing-bind.manifest")
43
43
 
44
+ # --- migration: a project's routing.yaml is never overwritten -------------
45
+ # `update-framework.sh` leaves a project's own routing.yaml alone on purpose, so a block added to
46
+ # the framework default after the project was scaffolded would never reach it. Both delivery
47
+ # routes run THIS script, so the migration lives here: a missing block is APPENDED, with the
48
+ # Claude aliases filled in (opus/sonnet/haiku exist on every account) and the OpenCode lines left
49
+ # empty, because guessing a model id for a provider this machine may not have would produce a
50
+ # fallback that fails at the moment it is needed. Existing lines are never touched, and a file
51
+ # that already has the block is left exactly as it is.
52
+ MIGRATIONS = [
53
+ ("fallbacks:", """
54
+ # Which models this tier drops to while its own is limited, in order, comma-separated.
55
+ # Added by tf-routing-bind.sh. The opencode lines are OpenCode Go models
56
+ # (https://opencode.ai/docs/go); change them if this project uses a different provider —
57
+ # `opencode models` prints what this machine can reach.
58
+ # The "limited until <time>" record is kept in this project, at
59
+ # .tfcore/.session/model-cooldown.json. Uncomment to move it (relative to the project root, or
60
+ # absolute); several projects sharing one file learn about a limit from each other.
61
+ # cooldown_file: .tfcore/.session/model-cooldown.json
62
+ # Full explanation: docs/TechieFlow-Routing-Guide.md §9.
63
+ fallbacks:
64
+ frontier:
65
+ claude: opus, haiku
66
+ opencode: opencode-go/glm-5.3, opencode-go/kimi-k3, openai/gpt-5.6-sol
67
+ standard:
68
+ claude: haiku
69
+ opencode: opencode-go/mimo-v2.5, opencode-go/qwen3.8-max, openai/gpt-5.6-luna
70
+ economy:
71
+ claude: sonnet
72
+ opencode: opencode-go/deepseek-v4-flash, opencode-go/glm-5.3-flash, openai/gpt-5.6-luna
73
+ """),
74
+ ("billing:", """
75
+ # How each harness is paid for, so a cost figure knows what it means (SCHEMA.md §2.5b).
76
+ # subscription = a flat fee, marginal dollars are zero; metered = an API key, real money;
77
+ # local = no bill; auto = work it out per model from OpenCode's own sign-in file.
78
+ # Add a provider by name here to override the guess, e.g. " myserver: local".
79
+ billing:
80
+ claude: subscription
81
+ opencode: auto
82
+ """),
83
+ ]
84
+ try:
85
+ _txt = open(ryaml, encoding="utf-8").read()
86
+ _added = []
87
+ for key, block in MIGRATIONS:
88
+ if not re.search(r"^%s\s*$" % re.escape(key), _txt, re.M):
89
+ if not _txt.endswith("\n"):
90
+ _txt += "\n"
91
+ _txt += block
92
+ _added.append(key.rstrip(":"))
93
+ if _added:
94
+ with open(ryaml, "w", encoding="utf-8", newline="\n") as _fh:
95
+ _fh.write(_txt)
96
+ print("tf-routing-bind: added %s to .tfcore/routing.yaml (nothing existing was changed)"
97
+ % " and ".join(_added))
98
+ except FileNotFoundError:
99
+ pass
100
+
44
101
  # --- parse routing.yaml (flat format — see its header) --------------------
45
102
  cfg = {"enabled": False, "phases": {}, "subagents": {}, "tiers": {}, "effort": {}}
46
103
  sect = tier = None
@@ -67,8 +124,37 @@ try:
67
124
  except FileNotFoundError:
68
125
  pass # no routing.yaml -> treat as disabled
69
126
 
127
+ # A tier resolves to its own model UNLESS that model is on cooldown — a usage limit recorded by
128
+ # tf-goal.sh in the per-machine file tf-model-pick.sh owns. Binding the fallback here is what stops
129
+ # the sub-agents failing behind a main agent that already moved (Routing-Guide §8, 2026-09-06).
130
+ _PICK = os.path.join(root, ".tfcore", "utils", "tf-model-pick.sh")
131
+ _picked = {}
132
+
133
+
70
134
  def model_for(t, harness):
71
- return cfg["tiers"].get(t, {}).get(harness)
135
+ declared = cfg["tiers"].get(t, {}).get(harness)
136
+ if not declared or t == "inherit":
137
+ return declared
138
+ if (t, harness) not in _picked:
139
+ got = declared
140
+ if os.path.isfile(_PICK):
141
+ try:
142
+ import subprocess
143
+ out = subprocess.run(["bash", _PICK, "pick", t, harness],
144
+ capture_output=True, text=True, timeout=20)
145
+ cand = (out.stdout or "").strip().splitlines()
146
+ cand = cand[-1].strip() if cand else ""
147
+ if cand and cand != "inherit":
148
+ got = cand
149
+ elif cand == "inherit":
150
+ got = None # every model in the chain is limited: bind nothing, let it wait
151
+ except Exception:
152
+ pass
153
+ if got != declared:
154
+ print("tf-routing-bind: tier %s (%s) is on %s — %s is on cooldown"
155
+ % (t, harness, got or "no model: the whole chain is limited", declared))
156
+ _picked[(t, harness)] = got
157
+ return _picked[(t, harness)]
72
158
 
73
159
  # --- remove previously generated artifacts (manifest-driven) --------------
74
160
  removed = 0
@@ -210,6 +210,13 @@ sub = ["%s=%s" % (k, v) for k, v in sorted(cfg["subagents"].items())]
210
210
  if sub:
211
211
  print("Subagents: " + ", ".join(sub))
212
212
  print()
213
+ print("When a model is limited (docs/TechieFlow-Routing-Guide.md §9):")
214
+ print(" what each tier would run right now, and what is parked:")
215
+ print(" bash .tfcore/utils/tf-model-pick.sh status")
216
+ print(" the order a tier falls back in: ... chain <tier> <claude|opencode>")
217
+ print(" unpark by hand: ... clear <harness> [model]")
218
+ print(" The supervisor parks a model by itself on a usage limit; tf-goal.sh --no-fallback stops it.")
219
+ print()
213
220
  print("Escalation (ADVISORY — you apply it when you launch; nothing switches a model mid-run):")
214
221
  if cfg["escalation"]:
215
222
  for ph in sorted(cfg["escalation"]):