@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.
- package/.claude/commands/TechieFlow/agents/analyst.md +5 -8
- package/.claude/commands/TechieFlow/agents/architect.md +1 -10
- package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_owner-language.md +27 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
- package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
- package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/generate-html.md +7 -3
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +20 -18
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
- package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
- package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
- package/.opencode/plugin/techieflow.js +181 -9
- package/.tfcore/agents/analyst.md +5 -8
- package/.tfcore/agents/architect.md +1 -10
- package/.tfcore/agents/flow-master.md +1 -0
- package/.tfcore/agents/verifier.md +1 -0
- package/.tfcore/hooks/block-git.sh +4 -2
- package/.tfcore/hooks/guard-build.sh +5 -1
- package/.tfcore/hooks/guard-db.sh +6 -4
- package/.tfcore/hooks/guard-metrics.sh +13 -1
- package/.tfcore/hooks/guard-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify-deps.sh +124 -0
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/routing.yaml +61 -0
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_owner-language.md +27 -0
- package/.tfcore/tasks/_smoke-test-policy.md +1 -1
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/amend-docs.md +6 -5
- package/.tfcore/tasks/build-phase.md +1 -1
- package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
- package/.tfcore/tasks/create-doc.md +4 -0
- package/.tfcore/tasks/create-project-brief.md +22 -0
- package/.tfcore/tasks/day1-brownfield.md +3 -3
- package/.tfcore/tasks/day1-greenfield.md +3 -3
- package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
- package/.tfcore/tasks/fix-issues.md +2 -2
- package/.tfcore/tasks/generate-html.md +7 -3
- package/.tfcore/tasks/handoff-phase.md +20 -18
- package/.tfcore/tasks/metrics-report.md +2 -0
- package/.tfcore/tasks/mockups.md +1 -1
- package/.tfcore/tasks/productguide.md +2 -2
- package/.tfcore/tasks/render-workflow-docs.md +1 -1
- package/.tfcore/tasks/triage-issues.md +2 -2
- package/.tfcore/tasks/verify-phase.md +1 -1
- package/.tfcore/telemetry/SCHEMA.md +61 -1
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +435 -21
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
- package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
- package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
- package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
- package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
- package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
- package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
- package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-assets-browser.mjs +96 -0
- package/.tfcore/utils/tf-assets.sh +97 -12
- package/.tfcore/utils/tf-brd-status.py +14 -5
- package/.tfcore/utils/tf-build-list.py +99 -11
- package/.tfcore/utils/tf-build-list.sh +5 -3
- package/.tfcore/utils/tf-build.sh +88 -15
- package/.tfcore/utils/tf-ci-repro.py +685 -0
- package/.tfcore/utils/tf-ci-repro.sh +24 -0
- package/.tfcore/utils/tf-devguide-list.py +47 -22
- package/.tfcore/utils/tf-doc-check.py +242 -10
- package/.tfcore/utils/tf-doc-tests.sh +79 -0
- package/.tfcore/utils/tf-emit.sh +413 -19
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +113 -31
- package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
- package/.tfcore/utils/tf-goal.sh +105 -18
- package/.tfcore/utils/tf-harness.sh +9 -4
- package/.tfcore/utils/tf-lock.sh +29 -0
- package/.tfcore/utils/tf-log-miss.py +34 -8
- package/.tfcore/utils/tf-login.mjs +167 -0
- package/.tfcore/utils/tf-misses-md.py +24 -21
- package/.tfcore/utils/tf-misses-md.sh +5 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
- package/.tfcore/utils/tf-mockup-parity.sh +14 -2
- package/.tfcore/utils/tf-model-pick.sh +475 -0
- package/.tfcore/utils/tf-owner-text.py +233 -0
- package/.tfcore/utils/tf-owner-text.sh +14 -0
- package/.tfcore/utils/tf-phase.sh +33 -1
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +80 -2
- package/.tfcore/utils/tf-routing-bind.sh +87 -1
- package/.tfcore/utils/tf-routing.sh +7 -0
- package/.tfcore/utils/tf-selfcheck.sh +356 -0
- package/.tfcore/utils/tf-split-brd.py +17 -2
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +133 -44
- package/.tfcore/utils/tf-triage.py +62 -16
- package/.tfcore/utils/tf-triage.sh +2 -0
- package/.tfcore/utils/tf-verify-boot.sh +263 -43
- package/.tfcore/utils/tf-verify-emit.sh +20 -0
- package/.tfcore/utils/tf-verify-env.sh +33 -4
- package/.tfcore/utils/tf-verify-list.py +75 -8
- package/.tfcore/utils/tf-verify-screens.mjs +254 -43
- package/.tfcore/utils/tf-verify-screens.sh +18 -4
- package/.tfcore/utils/tf-verify-tests.sh +272 -28
- package/.tfcore/utils/tf-verify-verdict.py +59 -11
- package/.tfcore/utils/tf_defect.py +27 -0
- package/.tfcore/utils/tf_feedback.py +209 -0
- package/.tfcore/utils/tf_roadmap.py +35 -0
- package/README.md +19 -5
- package/docs/TechieFlow-Installation.md +5 -3
- package/opencode.jsonc +24 -0
- package/package.json +1 -1
- package/scripts/install.mjs +4 -0
- package/.tfcore/templates/architecture-tmpl.yaml +0 -650
- package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
- package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
- package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
- package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
- package/.tfcore/templates/market-research-tmpl.yaml +0 -252
- package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
- 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,
|
|
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
|
-
|
|
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
|