@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
@@ -74,7 +74,7 @@ DRY = os.environ["TF_DRY"] == "1"
74
74
  # ones this audit exists to catch. The file system is the fact.
75
75
  STACKS = [
76
76
  ("dotnet", (".csproj", ".fsproj", ".vbproj", ".sln", ".slnx"),
77
- ["bin/", "obj/", "*.user", "TestResults/"]),
77
+ ["bin/", "obj/", "*.user", "TestResults/", ".vs/"]),
78
78
  ("node", ("package.json",), ["dist/", "build/", ".next/", ".nuxt/", ".turbo/"]),
79
79
  ("python", ("pyproject.toml", "setup.py", "requirements.txt"),
80
80
  ["__pycache__/", "*.py[cod]", ".venv/", "venv/", ".pytest_cache/", "*.egg-info/"]),
@@ -83,6 +83,14 @@ STACKS = [
83
83
  ("java", ("pom.xml", "build.gradle", "build.gradle.kts"), ["target/", "build/", ".gradle/"]),
84
84
  ]
85
85
 
86
+ # Per-developer IDE state, wanted whatever the stack is. It is not build output, but it is
87
+ # the same harm and this tool is the only thing looking: TfLens tracked three .bin files
88
+ # under .vs/ProjectEvaluation/, one carrying 246 absolute paths out of one developer's
89
+ # machine, while the audit ran on that repo every week and said nothing. `.vscode/` is NOT
90
+ # here -- a team often commits it deliberately, and a rule that fights the owner gets
91
+ # switched off. tests/regression/run.sh tf_014.
92
+ IDE_STATE = [".vs/", ".idea/"]
93
+
86
94
  # `package.json` alone does NOT make a repo a Node project, and this exception is
87
95
  # load-bearing rather than fussy: verify-phase §1 runs `npm init -y` in EVERY repo
88
96
  # to provision Playwright, so a bare package.json sits in essentially every .NET
@@ -114,14 +122,19 @@ def _is_real_node(root, files):
114
122
  # `npm init -y` writes exactly one script: a `test` stub that echoes and exits 1.
115
123
  return any(k in scripts for k in ("build", "dist", "bundle", "compile"))
116
124
 
125
+ # A NAME list, never a blanket dot-rule. `not d.startswith(".")` used to prune every
126
+ # dot-directory from the walk -- .vs/, .idea/, .gradle/, .terraform/, .pytest_cache/ --
127
+ # which is close to a complete list of the per-developer state a gitignore audit is FOR.
128
+ # The tool cannot report a folder it never visits, and its silence read exactly like a pass.
117
129
  SKIP_DIRS = {".git", "node_modules", ".tfcore", "bin", "obj", "target", "dist",
118
- "build", ".venv", "venv", "__pycache__", ".next"}
130
+ "build", ".venv", "venv", "__pycache__", ".next", ".claude", ".opencode",
131
+ ".vscode", ".github", ".husky", ".yarn"}
119
132
 
120
133
 
121
134
  def detect():
122
135
  found = set()
123
136
  for root, dirs, files in os.walk(REPO):
124
- dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
137
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
125
138
  if root.count(os.sep) - REPO.count(os.sep) > 3:
126
139
  dirs[:] = []
127
140
  for name, markers, _ in STACKS:
@@ -154,6 +167,24 @@ def covered(rule, lines):
154
167
  return False
155
168
 
156
169
 
170
+ def child_only_rules(rule, lines):
171
+ """Rules that ignore something INSIDE `rule` while `rule` itself stays open.
172
+
173
+ This shape is more dangerous than no rule at all, because it looks deliberate:
174
+ TfLens carried `/.vs/App.slnx`, which covers the solution-named subfolder and
175
+ leaves `.vs/ProjectEvaluation/` tracked. The next folder the IDE creates is not
176
+ covered either, and nobody re-reads a line that already mentions the directory."""
177
+ core = rule.strip("/*").rstrip("/")
178
+ out = []
179
+ for l in lines:
180
+ if not l or l.startswith("#"):
181
+ continue
182
+ c = l.lstrip("!").strip().replace("**/", "").strip("/")
183
+ if c != core and (c.startswith(core + "/") or c.startswith(core + "\\")):
184
+ out.append(l.strip())
185
+ return out
186
+
187
+
157
188
  def tracked_paths():
158
189
  """Read .git/index directly. Returns the set of tracked paths, or None when the
159
190
  index cannot be read (a bare repo, a worktree file, a version we do not know) —
@@ -210,8 +241,18 @@ wanted = []
210
241
  for name, _, rules in STACKS:
211
242
  if name in stacks:
212
243
  wanted += [(name, r) for r in rules]
244
+ if stacks:
245
+ wanted += [("ide", r) for r in IDE_STATE if not any(r == w for _n, w in wanted)]
213
246
 
214
247
  missing = [(s, r) for s, r in wanted if not covered(r, lines)]
248
+ # A rule naming a child of a directory that is itself unignored: report it separately,
249
+ # because "MISSING .vs/" reads like nobody thought about it when in fact somebody did
250
+ # and got it half right, which is the harder thing to spot in a review.
251
+ half_done = []
252
+ for _s, r in missing:
253
+ kids = child_only_rules(r, lines)
254
+ if kids:
255
+ half_done.append((r, kids))
215
256
 
216
257
  tracked = tracked_paths()
217
258
  offenders = {}
@@ -244,6 +285,12 @@ else:
244
285
  print(" ⚠ MISSING build-output rules: %s" % names)
245
286
  print(" add them with: bash .tfcore/utils/tf-gitignore-audit.sh . --fix")
246
287
 
288
+ for r, kids in half_done:
289
+ rc |= 1
290
+ print(" ⚠ %s is not ignored, but %s covers a child of it — the parent is open"
291
+ % (r, ", ".join(kids)))
292
+ print(" a rule on a child looks deliberate and leaves every sibling tracked")
293
+
247
294
  if tracked is None:
248
295
  print(" tracked-file check skipped (no readable .git/index) — rules alone do not")
249
296
  print(" prove build output is untracked; re-run where the index is readable")
@@ -2,9 +2,9 @@
2
2
  # tf-goal.sh — unattended GOAL runner / supervisor for TechieFlow (rule: .tfcore/tasks/_yolo-mode.md)
3
3
  #
4
4
  # Runs a goal headless in YOLO mode and keeps it running until the agent itself
5
- # declares the goal done — surviving the subscription 5-hour / weekly usage
6
- # limits (sleeps until the stated reset + a buffer, then RESUMES THE SAME
7
- # SESSION), crashes, and "I stopped early to ask you something" turns.
5
+ # declares the goal done — surviving the subscription 5-hour / weekly usage limits (moves to the
6
+ # next model in the tier's fallback chain, or sleeps until the stated reset + a buffer when there
7
+ # is none, then RESUMES THE SAME SESSION), crashes, and "I stopped early to ask you something".
8
8
  #
9
9
  # bash .tfcore/utils/tf-goal.sh [options] <app-dir> "<goal text>"
10
10
  # bash .tfcore/utils/tf-goal.sh [options] <app-dir> @goal.md
@@ -12,6 +12,10 @@
12
12
  # Options
13
13
  # --harness claude|opencode default: claude
14
14
  # --model <id> claude: --model; opencode: -m
15
+ # --tier <tier> which routing tier this run belongs to, for the fallback chain
16
+ # (default standard) — .tfcore/routing.yaml `fallbacks:`
17
+ # --no-fallback never switch model on a usage limit; sleep until the reset instead
18
+ # (the behaviour before 2026-09-10)
15
19
  # --buffer-min <n> minutes added after a stated limit-reset time (default 15)
16
20
  # --probe-min <n> limit hit but NO reset time parseable → fire a one-turn probe every n
17
21
  # minutes until the API answers again, then resume (default 15)
@@ -41,6 +45,14 @@
41
45
  # goal.log everything the harness printed, all cycles, timestamped
42
46
  # goal-done.json the agent's completion sentinel (tf-yolo.sh done [complete|blocked])
43
47
  #
48
+ # WHEN A MODEL RUNS OUT (added 2026-09-10, MISS-TechieFlow-20260910-01). A usage limit used to mean
49
+ # one thing: sleep until the reset. It now means: park that model in the per-machine cooldown file
50
+ # (tf-model-pick.sh), ask the tier for the next model in its `fallbacks:` chain, re-bind so the
51
+ # sub-agents move too, and start the next cycle straight away on that model. Only when the whole
52
+ # chain is parked does the supervisor sleep, exactly as before. If the fallback is limited as well
53
+ # the next cycle parks that one too, so an account-wide limit — every Claude model at once — costs
54
+ # one wasted cycle and then behaves as it always did. `--no-fallback` turns all of it off.
55
+ #
44
56
  # How the agent is told to finish: the prompt preamble (below) instructs it to run
45
57
  # `bash .tfcore/utils/tf-yolo.sh done complete "<summary>"` when the goal is met,
46
58
  # or `... done blocked "<why>"` when only the owner can unblock it. A cycle that
@@ -59,11 +71,14 @@ set -u
59
71
 
60
72
  HARNESS="claude"; MODEL=""; BUFFER_MIN=15; DEFAULT_WAIT_MIN=60; MAX_CYCLES=60; IDLE_RETRY=30
61
73
  PROBE_MIN=15; PROBE_MAX_H=8; STALL_MIN=15
74
+ TIER="standard"; FALLBACK=1
62
75
  RESUME=0; FRESH=0; DRY=0
63
76
  while [[ $# -gt 0 ]]; do
64
77
  case "$1" in
65
78
  --harness) HARNESS="$2"; shift 2 ;;
66
79
  --model) MODEL="$2"; shift 2 ;;
80
+ --tier) TIER="$2"; shift 2 ;;
81
+ --no-fallback) FALLBACK=0; shift ;;
67
82
  --buffer-min) BUFFER_MIN="$2"; shift 2 ;;
68
83
  --default-wait-min) DEFAULT_WAIT_MIN="$2"; shift 2 ;;
69
84
  --probe-min) PROBE_MIN="$2"; shift 2 ;;
@@ -74,7 +89,7 @@ while [[ $# -gt 0 ]]; do
74
89
  --resume) RESUME=1; shift ;;
75
90
  --fresh) FRESH=1; shift ;;
76
91
  --dry-run) DRY=1; shift ;;
77
- -h|--help) sed -n '2,45p' "$0"; exit 0 ;;
92
+ -h|--help) sed -n '2,48p' "$0"; exit 0 ;;
78
93
  --) shift; break ;;
79
94
  -*) echo "unknown option $1" >&2; exit 2 ;;
80
95
  *) break ;;
@@ -88,11 +103,15 @@ fi
88
103
  APP_DIR="$(cd "$APP_DIR" 2>/dev/null && pwd)" || { echo "no such dir: $1" >&2; exit 2; }
89
104
  [[ -d "$APP_DIR/.tfcore" ]] || { echo "$APP_DIR has no .tfcore/ — scaffold it first" >&2; exit 2; }
90
105
  case "$HARNESS" in claude|opencode) ;; *) echo "--harness must be claude|opencode" >&2; exit 2 ;; esac
106
+ case "$TIER" in frontier|standard|economy) ;; *) echo "--tier must be frontier|standard|economy" >&2; exit 2 ;; esac
91
107
  command -v python3 >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 2; }
92
108
 
93
109
  STATE_DIR="$APP_DIR/.tfcore/.session"; mkdir -p "$STATE_DIR"
94
110
  STATE="$STATE_DIR/goal.json"; LOG="$STATE_DIR/goal.log"; DONE="$STATE_DIR/goal-done.json"
95
111
  YOLO_SH="$APP_DIR/.tfcore/utils/tf-yolo.sh"
112
+ PICK_SH="$APP_DIR/.tfcore/utils/tf-model-pick.sh"
113
+ BIND_SH="$APP_DIR/.tfcore/utils/tf-routing-bind.sh"
114
+ [[ -f "$PICK_SH" ]] || FALLBACK=0 # a project that predates tf-model-pick.sh keeps the old behaviour
96
115
 
97
116
  if [[ "$GOAL_ARG" == @* ]]; then
98
117
  GOAL_FILE="${GOAL_ARG#@}"; [[ -f "$GOAL_FILE" ]] || { echo "goal file not found: $GOAL_FILE" >&2; exit 2; }
@@ -126,6 +145,10 @@ if [[ $RESUME -eq 1 ]]; then
126
145
  [[ -f "$STATE" ]] || { echo "--resume: no $STATE" >&2; exit 2; }
127
146
  [[ -z "$GOAL" ]] && GOAL="$(state_get goal)"
128
147
  HARNESS="$(state_get harness)"; HARNESS="${HARNESS:-claude}"
148
+ # A resume keeps the model a fallback moved to, and the tier its chain came from; an explicit
149
+ # --model on the resume command still wins.
150
+ _t="$(state_get tier)"; [[ -n "$_t" ]] && TIER="$_t"
151
+ _m="$(state_get model)"; [[ -n "$_m" && -z "$MODEL" ]] && MODEL="$_m"
129
152
  CYCLE="$(state_get cycle)"; CYCLE="${CYCLE:-0}"
130
153
  SESSION_ID="$(state_get session_id)"
131
154
  STALLS="$(state_get stalls)"; STALLS="${STALLS:-0}"
@@ -144,7 +167,7 @@ else
144
167
  fi
145
168
  CYCLE=0; SESSION_ID=""; STALLS=0
146
169
  if [[ $DRY -eq 0 ]]; then # a dry run touches nothing: no state, no flag, no log line
147
- state_set goal "$GOAL" harness "$HARNESS" cycle 0 session_id "" stalls 0 started "$(ts)" last_reason "start"
170
+ state_set goal "$GOAL" harness "$HARNESS" tier "$TIER" model "$MODEL" cycle 0 session_id "" stalls 0 started "$(ts)" last_reason "start"
148
171
  rm -f "$DONE"
149
172
  fi
150
173
  fi
@@ -346,6 +369,60 @@ print(sid)
346
369
  PY
347
370
  }
348
371
 
372
+ extract_model() { # the model the harness reported for this cycle, from its own output
373
+ python3 - "$1" <<'PY' 2>/dev/null
374
+ import sys, json, re
375
+ m = ""
376
+ try:
377
+ for line in open(sys.argv[1], errors="replace"):
378
+ line = line.strip()
379
+ if not line.startswith("{") or '"model"' not in line:
380
+ continue
381
+ try: d = json.loads(line)
382
+ except Exception: continue
383
+ if not isinstance(d, dict): continue
384
+ if isinstance(d.get("model"), str) and d["model"]:
385
+ m = d["model"]
386
+ msg = d.get("message")
387
+ if isinstance(msg, dict) and isinstance(msg.get("model"), str) and msg["model"]:
388
+ m = msg["model"]
389
+ except Exception:
390
+ pass
391
+ print(m)
392
+ PY
393
+ }
394
+
395
+ # Park the model this cycle ran on and move to the next one in its tier. Prints nothing and
396
+ # returns 1 when there is nowhere to go — the caller then sleeps, which is what it always did.
397
+ # $1 = epoch the limit is stated to reset at (or empty when it could not be parsed)
398
+ # $2 = why, for the cooldown record
399
+ try_fallback() {
400
+ [[ $FALLBACK -eq 1 ]] || return 1
401
+ local until="${1:-}" why="${2:-usage limit}" cur next
402
+ cur="$MODEL"
403
+ [[ -z "$cur" ]] && cur="$(extract_model "$OUT")"
404
+ if [[ -z "$cur" ]]; then
405
+ cur="$(TF_PROJECT_DIR="$APP_DIR" bash "$PICK_SH" pick "$TIER" "$HARNESS" 2>/dev/null | tail -1)"
406
+ [[ "$cur" == inherit ]] && cur=""
407
+ fi
408
+ if [[ -z "$cur" ]]; then
409
+ log "no fallback: nothing here says which model this cycle ran on (no --model, no model id in the output, no routing answer for tier $TIER)"
410
+ return 1
411
+ fi
412
+ [[ -z "$until" ]] && until="probe"
413
+ TF_PROJECT_DIR="$APP_DIR" bash "$PICK_SH" cooldown "$HARNESS" "$cur" "$until" "$why" >>"$LOG" 2>&1
414
+ next="$(TF_PROJECT_DIR="$APP_DIR" bash "$PICK_SH" pick "$TIER" "$HARNESS" 2>/dev/null | tail -1)"
415
+ if [[ -z "$next" || "$next" == "inherit" ]]; then
416
+ log "no fallback left: every model in the $TIER chain is on cooldown — waiting for the reset instead"
417
+ return 1
418
+ fi
419
+ MODEL="$next"
420
+ state_set model "$MODEL" fallback_from "$cur" last_reason "fallback:$cur->$MODEL"
421
+ log "FALLBACK: $cur is limited ($why) — the next cycle runs on $MODEL (tier $TIER). Re-binding so the sub-agents move too."
422
+ [[ -f "$BIND_SH" ]] && bash "$BIND_SH" "$APP_DIR" 2>&1 | sed 's/^/ /' | tee -a "$LOG" >&2
423
+ return 0
424
+ }
425
+
349
426
  # Fallback when a limit message carries no parseable reset time: fire a one-turn
350
427
  # probe every PROBE_MIN minutes until it comes back clean (exit 0 AND no limit
351
428
  # wording in its output). Gives up after PROBE_MAX_H hours and resumes anyway.
@@ -507,7 +584,14 @@ while :; do
507
584
  CLASS=IDLE; DETAIL="stalled ${STALL_MIN}m"; HOW=""
508
585
  PERR="$(opencode_log_error "$CYCLE_START")"
509
586
  if [[ -n "$PERR" ]] && grep -qiE "usage limit|monthly|balance|quota|rate limit|429|insufficient|billing|unauthorized|api key" <<<"$PERR"; then
510
- log "the provider refused the model while the harness printed nothing (cycle $CYCLE): $PERR — the owner must act (enable balance, wait for the reset, or pick another model); stopping"
587
+ log "the provider refused the model while the harness printed nothing (cycle $CYCLE): $PERR"
588
+ # A monthly limit or an empty balance states no reset time, so the model is parked for the
589
+ # probe window and the run continues on the next model in the tier. Only when the chain is
590
+ # exhausted does this stay what it was before 2026-09-10: the owner's problem, exit 5.
591
+ if try_fallback "" "provider refused the model: ${PERR:0:120}"; then
592
+ STALLS=0; state_set stalls 0; BACKOFF=120; continue
593
+ fi
594
+ log "no other model in the $TIER chain is available — the owner must act (enable balance, wait for the reset, or pick another model); stopping"
511
595
  state_set last_reason "provider-limit" ended "$(ts)"
512
596
  exit 5
513
597
  fi
@@ -525,6 +609,9 @@ while :; do
525
609
  fi
526
610
  case "$CLASS" in
527
611
  LIMIT)
612
+ # Another model in the same tier beats waiting, when there is one.
613
+ if [[ "$HOW" == probe ]]; then _FB_UNTIL=""; else _FB_UNTIL="$DETAIL"; fi
614
+ if try_fallback "$_FB_UNTIL" "usage limit"; then BACKOFF=120; continue; fi
528
615
  if [[ "$HOW" == probe ]]; then
529
616
  log "USAGE LIMIT hit (cycle $CYCLE) but no reset time could be parsed from the message — probing every ${PROBE_MIN}m until the API answers again (max ${PROBE_MAX_H}h). Tail of the message:"
530
617
  tail -c 400 "$OUT" | tr '\n' ' ' | sed 's/^/ /' | tee -a "$LOG" >&2; echo >&2
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env python3
2
2
  """tf-misses-md.py — the readable miss list, docs/<App>-Misses.md (Session 5, 2026-09-07; FR-31).
3
3
 
4
- python3 .tfcore/utils/tf-misses-md.py [--root <repo>] [--app <App>] [--no-html] [--quiet]
4
+ python3 .tfcore/utils/tf-misses-md.py [--root <repo>] [--app <App>] [--quiet]
5
5
 
6
6
  Rebuilds docs/<App>-Misses.md from docs/metrics/misses.jsonl: one row per `miss` record, newest first,
7
7
  in three tables (open, fixed, will not fix), each row holding the miss id, the owning row, when and
8
8
  by whom it was found, whose gap it was (the `sort` field, folded from any miss-amend) and the `what`
9
- sentence. Then renders the sibling HTML through tf-render-html.py. tf-emit.sh calls this after every
10
- write to the misses stream, so the file is never older than the record; run it by hand once on a
11
- project whose stream predates it. The file is derived: never edit it, edit nothing, log a miss.
9
+ sentence. Markdown only — there is no HTML sibling: the miss log is read by agents and by the owner
10
+ in markdown, and tf-render-html.py refuses it (owner, 2026-09-08; an HTML copy nobody opened was
11
+ being re-rendered on every miss in every project). tf-emit.sh calls this after every write to the
12
+ misses stream, so the file is never older than the record; run it by hand once on a project whose
13
+ stream predates it. The file is derived: never edit it, edit nothing, log a miss.
12
14
  Exit 0 always (telemetry has no veto); the reason for a skipped write is printed unless --quiet.
13
15
  """
14
16
  import datetime
@@ -16,7 +18,6 @@ import glob
16
18
  import json
17
19
  import os
18
20
  import re
19
- import subprocess
20
21
  import sys
21
22
 
22
23
  HERE = os.path.dirname(os.path.abspath(__file__))
@@ -183,24 +184,20 @@ def main(argv):
183
184
  old = fh.read()
184
185
  except FileNotFoundError:
185
186
  pass
187
+ stale = out[:-3] + ".html" # left by a pre-2026-09-08 run, before the miss
188
+ gone = "" # log was ruled an agent document (owner)
189
+ if os.path.isfile(stale):
190
+ os.remove(stale)
191
+ gone = "; removed the stale HTML copy"
186
192
  same = lambda s: re.sub(r"\| Updated \| [0-9-]+ \|", "", s) # everything but the date must match
187
- if old is not None and same(old) == same(text) and os.path.isfile(out[:-3] + ".html"):
193
+ if old is not None and same(old) == same(text):
188
194
  if not quiet:
189
- print(f"tf-misses-md: {rel} unchanged ({n} misses)")
195
+ print(f"tf-misses-md: {rel} unchanged ({n} misses){gone}")
190
196
  return 0
191
197
  with open(out, "w", encoding="utf-8", newline="\n") as fh:
192
198
  fh.write(text)
193
- html = "skipped"
194
- if "--no-html" not in argv:
195
- r = os.path.join(HERE, "tf-render-html.py")
196
- if os.path.isfile(r):
197
- try:
198
- p = subprocess.run([sys.executable, r, out, "--quiet"], capture_output=True, text=True, timeout=120)
199
- html = "rendered" if p.returncode == 0 else "not rendered (" + (p.stderr or p.stdout).strip().splitlines()[-1:][0][:100] + ")" if (p.stderr or p.stdout).strip() else "not rendered"
200
- except Exception as e:
201
- html = f"not rendered ({e})"
202
199
  if not quiet:
203
- print(f"tf-misses-md: wrote {rel} ({n} misses); HTML {html}")
200
+ print(f"tf-misses-md: wrote {rel} ({n} misses){gone}")
204
201
  except Exception as e:
205
202
  if not quiet:
206
203
  print(f"tf-misses-md: nothing written ({e})")
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env bash
2
- # tf-misses-md.sh — rebuild the readable miss list docs/<App>-Misses.md and its HTML from the
3
- # misses stream (Session 5, 2026-09-07; FR-31). tf-emit.sh runs it after every write to that
4
- # stream; run it by hand once on a project whose stream is older than this script.
2
+ # tf-misses-md.sh — rebuild the readable miss list docs/<App>-Misses.md from the misses
3
+ # stream (Session 5, 2026-09-07; FR-31). Markdown only: the miss log is never rendered to
4
+ # HTML. tf-emit.sh runs it after every write to that stream; run it by hand once on a
5
+ # project whose stream is older than this script.
5
6
  #
6
- # bash .tfcore/utils/tf-misses-md.sh [--root <repo>] [--app <App>] [--no-html] [--quiet]
7
+ # bash .tfcore/utils/tf-misses-md.sh [--root <repo>] [--app <App>] [--quiet]
7
8
  #
8
9
  # Exit 0 always. Details: tf-misses-md.py.
9
10
  set -u