@techierathore/techieflow 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) 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 +27 -0
  6. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
  7. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
  8. package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
  9. package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
  10. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
  11. package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
  12. package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
  13. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
  14. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +3 -3
  15. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
  16. package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
  17. package/.claude/commands/TechieFlow/tasks/generate-html.md +7 -3
  18. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +20 -18
  19. package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
  20. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  21. package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
  22. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
  23. package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
  24. package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
  25. package/.opencode/plugin/techieflow.js +181 -9
  26. package/.tfcore/agents/analyst.md +5 -8
  27. package/.tfcore/agents/architect.md +1 -10
  28. package/.tfcore/agents/flow-master.md +1 -0
  29. package/.tfcore/agents/verifier.md +1 -0
  30. package/.tfcore/hooks/block-git.sh +4 -2
  31. package/.tfcore/hooks/guard-build.sh +5 -1
  32. package/.tfcore/hooks/guard-db.sh +6 -4
  33. package/.tfcore/hooks/guard-metrics.sh +13 -1
  34. package/.tfcore/hooks/guard-status-html.sh +86 -2
  35. package/.tfcore/hooks/guard-verify-deps.sh +124 -0
  36. package/.tfcore/hooks/guard-verify.sh +8 -3
  37. package/.tfcore/hooks/sweep-artifacts.sh +46 -0
  38. package/.tfcore/routing.yaml +61 -0
  39. package/.tfcore/standards/owner-words.txt +23 -0
  40. package/.tfcore/tasks/_owner-language.md +27 -0
  41. package/.tfcore/tasks/_smoke-test-policy.md +1 -1
  42. package/.tfcore/tasks/_status-update-gate.md +4 -3
  43. package/.tfcore/tasks/amend-docs.md +6 -5
  44. package/.tfcore/tasks/build-phase.md +1 -1
  45. package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
  46. package/.tfcore/tasks/create-doc.md +4 -0
  47. package/.tfcore/tasks/create-project-brief.md +22 -0
  48. package/.tfcore/tasks/day1-brownfield.md +3 -3
  49. package/.tfcore/tasks/day1-greenfield.md +3 -3
  50. package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
  51. package/.tfcore/tasks/fix-issues.md +2 -2
  52. package/.tfcore/tasks/generate-html.md +7 -3
  53. package/.tfcore/tasks/handoff-phase.md +20 -18
  54. package/.tfcore/tasks/metrics-report.md +2 -0
  55. package/.tfcore/tasks/mockups.md +1 -1
  56. package/.tfcore/tasks/productguide.md +2 -2
  57. package/.tfcore/tasks/render-workflow-docs.md +1 -1
  58. package/.tfcore/tasks/triage-issues.md +2 -2
  59. package/.tfcore/tasks/verify-phase.md +1 -1
  60. package/.tfcore/telemetry/SCHEMA.md +61 -1
  61. package/.tfcore/telemetry/install-metrics.sh +3 -2
  62. package/.tfcore/telemetry/tf-metrics.sh +435 -21
  63. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
  64. package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
  65. package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
  66. package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
  67. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
  68. package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
  69. package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
  70. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
  71. package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
  72. package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
  73. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  74. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  75. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  76. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  77. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  78. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  79. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  80. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  81. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  82. package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
  83. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  84. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  85. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  86. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  87. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  88. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  89. package/.tfcore/utils/tf-assets-browser.mjs +96 -0
  90. package/.tfcore/utils/tf-assets.sh +97 -12
  91. package/.tfcore/utils/tf-brd-status.py +14 -5
  92. package/.tfcore/utils/tf-build-list.py +99 -11
  93. package/.tfcore/utils/tf-build-list.sh +5 -3
  94. package/.tfcore/utils/tf-build.sh +88 -15
  95. package/.tfcore/utils/tf-ci-repro.py +685 -0
  96. package/.tfcore/utils/tf-ci-repro.sh +24 -0
  97. package/.tfcore/utils/tf-devguide-list.py +47 -22
  98. package/.tfcore/utils/tf-doc-check.py +242 -10
  99. package/.tfcore/utils/tf-doc-tests.sh +79 -0
  100. package/.tfcore/utils/tf-emit.sh +413 -19
  101. package/.tfcore/utils/tf-feedback.sh +13 -0
  102. package/.tfcore/utils/tf-fix-close.sh +113 -31
  103. package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
  104. package/.tfcore/utils/tf-goal.sh +105 -18
  105. package/.tfcore/utils/tf-harness.sh +9 -4
  106. package/.tfcore/utils/tf-lock.sh +29 -0
  107. package/.tfcore/utils/tf-log-miss.py +34 -8
  108. package/.tfcore/utils/tf-login.mjs +167 -0
  109. package/.tfcore/utils/tf-misses-md.py +24 -21
  110. package/.tfcore/utils/tf-misses-md.sh +5 -4
  111. package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
  112. package/.tfcore/utils/tf-mockup-parity.sh +14 -2
  113. package/.tfcore/utils/tf-model-pick.sh +475 -0
  114. package/.tfcore/utils/tf-owner-text.py +233 -0
  115. package/.tfcore/utils/tf-owner-text.sh +14 -0
  116. package/.tfcore/utils/tf-phase.sh +33 -1
  117. package/.tfcore/utils/tf-portable.sh +230 -0
  118. package/.tfcore/utils/tf-render-html.py +80 -2
  119. package/.tfcore/utils/tf-routing-bind.sh +87 -1
  120. package/.tfcore/utils/tf-routing.sh +7 -0
  121. package/.tfcore/utils/tf-selfcheck.sh +356 -0
  122. package/.tfcore/utils/tf-split-brd.py +17 -2
  123. package/.tfcore/utils/tf-status-evidence.sh +11 -9
  124. package/.tfcore/utils/tf-status-facts.py +133 -44
  125. package/.tfcore/utils/tf-triage.py +62 -16
  126. package/.tfcore/utils/tf-triage.sh +2 -0
  127. package/.tfcore/utils/tf-verify-boot.sh +263 -43
  128. package/.tfcore/utils/tf-verify-emit.sh +20 -0
  129. package/.tfcore/utils/tf-verify-env.sh +33 -4
  130. package/.tfcore/utils/tf-verify-list.py +75 -8
  131. package/.tfcore/utils/tf-verify-screens.mjs +254 -43
  132. package/.tfcore/utils/tf-verify-screens.sh +18 -4
  133. package/.tfcore/utils/tf-verify-tests.sh +272 -28
  134. package/.tfcore/utils/tf-verify-verdict.py +59 -11
  135. package/.tfcore/utils/tf_defect.py +27 -0
  136. package/.tfcore/utils/tf_feedback.py +209 -0
  137. package/.tfcore/utils/tf_roadmap.py +35 -0
  138. package/README.md +19 -5
  139. package/docs/TechieFlow-Installation.md +5 -3
  140. package/opencode.jsonc +24 -0
  141. package/package.json +1 -1
  142. package/scripts/install.mjs +4 -0
  143. package/.tfcore/templates/architecture-tmpl.yaml +0 -650
  144. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
  145. package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
  146. package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
  147. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
  148. package/.tfcore/templates/market-research-tmpl.yaml +0 -252
  149. package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
  150. package/.tfcore/utils/techieflow-doc-template.md +0 -325
@@ -5,6 +5,17 @@
5
5
  # --screen login=/login --screen harness=/harness --screen export=/export \
6
6
  # --cookie 'AuthCookie=<value from your Playwright login>' \
7
7
  # --json-out tests/.artifacts/mockup-parity/run.json
8
+ # A sign-in the page keeps for itself sets no cookie (AppManager TF-011): give it
9
+ # --login-path /login --user <u> --password <p> (or --storage-state <file>)
10
+ # and each screen is opened in a tab that signed in through the form, the way
11
+ # tf-verify-screens.sh does; `widths[].reached` in the JSON says how a 401 screen was reached.
12
+ # A desktop head (Lekhak TF-006): --cdp http://<host>:9223 in place of --base attaches to the
13
+ # running app, as tf-verify-screens.sh --cdp does, and opens each route by pushState.
14
+ # The app is compared in the mockup's theme: the mockup's data-*theme/mode/scheme attributes on
15
+ # <html> and <body>, and a light/dark mode class there (class="dark", Lekhak TF-013), are set on the
16
+ # app page for the comparison and put back after (Lekhak TF-010);
17
+ # --keep-app-theme compares the theme the app shows. A mockup box marked data-tf-state or
18
+ # data-state-testid (another state of the screen) is left out of the comparison (Lekhak TF-011).
8
19
  #
9
20
  # WHAT IT ASKS: "does the built screen carry the structure its approved mockup
10
21
  # draws?" — at the same viewports, comparing STRUCTURE, never pixels. Pixel diffing
@@ -47,7 +58,7 @@ set -uo pipefail
47
58
 
48
59
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
49
60
 
50
- case "${1:-}" in -h|--help) sed -n '2,42p' "$0"; exit 0 ;; esac
61
+ case "${1:-}" in -h|--help) sed -n '2,48p' "$0"; exit 0 ;; esac
51
62
 
52
63
  command -v node >/dev/null 2>&1 || {
53
64
  echo "tf-mockup-parity: node is required (verify-phase §1 installs the verify environment)." >&2
@@ -62,4 +73,5 @@ node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1
62
73
  exit 4
63
74
  }
64
75
 
65
- exec node "$HERE/tf-mockup-parity.mjs" "$@"
76
+ source "$HERE/tf-lock.sh"; tf_take_lock mockup-parity # one browser check at a time (TF-048)
77
+ node "$HERE/tf-mockup-parity.mjs" "$@"
@@ -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