@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
@@ -3,21 +3,34 @@
3
3
  #
4
4
  # bash .tfcore/utils/tf-fix-close.sh <App> [--started <ISO>] [--reqs REQ-UI-009,REQ-FN-014]
5
5
  # [--subagents trblazeui,builder] [--build pass|fail|not-run] [--files N]
6
+ # bash .tfcore/utils/tf-fix-close.sh <App> --misses MISS-App-20260901-06,MISS-App-20260830-03 \
7
+ # --fix-cmd amend-docs [--verdict Verified]
8
+ #
9
+ # The second form closes misses DIRECTLY, by id, with no checklist row and no verify ledger.
10
+ # It exists because a miss whose deficient artifact is a document is fixed by *amend-docs
11
+ # editing the BRD -- never by the verifier touching a row -- so the row-driven form above can
12
+ # never reach it, and the miss stayed open forever (TF-016). It writes NO run record: the
13
+ # amending command emits its own through the status gate. `fix_cmd: "amend-docs"` was already
14
+ # legal in SCHEMA §5.5.2; only this door was missing.
6
15
  #
7
16
  # Emits, in this order, through tf-emit.sh only:
8
17
  # 1. the fix-issues run record (cmd fix-issues, mode fix, the rows touched) — first, because the
9
- # miss-fix records below carry no numbers of their own: the emitter copies this run's token window
10
- # 2. one miss-fix per row that has an open miss, with verdict_after taken from the verify ledger
11
- # docs/.last-verify.json written by the verify the fix chained, mapped to a checklist status (PASS →
12
- # Verified; FAIL → FAIL; any other failing verdict or an absent row → Needs re-verify). A row with no open miss is listed,
18
+ # miss-fix records below carry no numbers of their own: the emitter copies this run's token window.
19
+ # A run the fix chained (the inline verify) keeps its own record, so the fix is recorded in the gaps
20
+ # around it; the first gap carries the rows and the fix's start (TF-052).
21
+ # 2. one miss-fix per row that has an open miss, with verdict_after taken from every verify the fix
22
+ # chained: the gate records written since the fix started, then the ledger docs/.last-verify.json
23
+ # when it belongs to this fix (PASS → Verified; FAIL → FAIL; any other failing verdict → Needs
24
+ # re-verify). A row no verify graded gets no miss-fix and is listed. A row with no open miss is listed,
13
25
  # not invented: a fix on a defect nobody logged is a triage gap, so open it first with tf-triage.sh
14
26
  # demote or tf-log-miss.sh, then re-run this.
15
- # --reqs defaults to every row in the ledger. Telemetry never blocks: refusals are printed, exit 0.
27
+ # --reqs defaults to every row those verifies graded. The start defaults to the fix's marker, or its
28
+ # "outer" entry when a chained verify replaced it. Telemetry never blocks: refusals are printed, exit 0.
16
29
  set -u
17
30
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
31
  [[ $# -ge 1 && "$1" != "-h" && "$1" != "--help" ]] || { sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 3; }
19
32
  APP="$1"; shift
20
- STARTED=""; REQS=""; SUBS=""; BUILD="pass"; FILES=""
33
+ STARTED=""; REQS=""; SUBS=""; BUILD="pass"; FILES=""; MISSES=""; FIXCMD="fix-issues"; VERDICT=""
21
34
  while [[ $# -gt 0 ]]; do
22
35
  case "$1" in
23
36
  --started) STARTED="${2:-}"; shift 2 ;;
@@ -25,11 +38,20 @@ while [[ $# -gt 0 ]]; do
25
38
  --subagents) SUBS="${2:-}"; shift 2 ;;
26
39
  --build) BUILD="${2:-}"; shift 2 ;;
27
40
  --files) FILES="${2:-}"; shift 2 ;;
41
+ --misses) MISSES="${2:-}"; shift 2 ;;
42
+ --fix-cmd) FIXCMD="${2:-}"; shift 2 ;;
43
+ --verdict) VERDICT="${2:-}"; shift 2 ;;
28
44
  *) echo "tf-fix-close: unknown argument $1" >&2; exit 3 ;;
29
45
  esac
30
46
  done
31
- [[ -z "$STARTED" ]] && STARTED="$(bash "$HERE/tf-phase.sh" show 2>/dev/null | sed -n 's/.*"started":"\([^"]*\)".*/\1/p')"
32
- TF_APP="$APP" TF_STARTED="$STARTED" TF_REQS="$REQS" TF_SUBS="$SUBS" TF_BUILD="$BUILD" TF_FILES="$FILES" TF_EMIT="$HERE/tf-emit.sh" python3 - <<'PY'
47
+ # the fix's start: its own marker, or the "outer" one when a verify it chained has replaced the marker
48
+ [[ -z "$STARTED" ]] && STARTED="$(bash "$HERE/tf-phase.sh" show 2>/dev/null | python3 -c "import json,sys
49
+ try: m=json.load(sys.stdin)
50
+ except Exception: m={}
51
+ o=m.get('outer') or {}
52
+ print(m.get('started','') if m.get('cmd')!='verify-phase' or not o.get('started') else o['started'])" 2>/dev/null)"
53
+ TF_APP="$APP" TF_STARTED="$STARTED" TF_REQS="$REQS" TF_SUBS="$SUBS" TF_BUILD="$BUILD" TF_FILES="$FILES" \
54
+ TF_MISSES="$MISSES" TF_FIXCMD="$FIXCMD" TF_VERDICT="$VERDICT" TF_EMIT="$HERE/tf-emit.sh" python3 - <<'PY'
33
55
  import datetime, json, os, subprocess
34
56
  EMIT = os.environ["TF_EMIT"]; app = os.environ["TF_APP"]; started = os.environ["TF_STARTED"]
35
57
  now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -39,45 +61,105 @@ def emit(stream, rec):
39
61
  msg = (p.stdout + p.stderr).strip(); ok = p.returncode == 0 and "refus" not in msg.lower() and "error" not in msg.lower()
40
62
  if not ok: print(f" {stream}: not written — {msg.splitlines()[0][:160] if msg else 'no reason printed'}")
41
63
  return ok
64
+ # --- close by miss id (the *amend-docs door) -------------------------------------------
65
+ direct = [m.strip() for m in (os.environ.get("TF_MISSES") or "").split(",") if m.strip()]
66
+ if direct:
67
+ fix_cmd = os.environ.get("TF_FIXCMD") or "amend-docs"
68
+ verdict = os.environ.get("TF_VERDICT") or "Verified"
69
+ closed = 0
70
+ for mid in direct:
71
+ fa = q("--next-fix-attempt", mid) or "1"
72
+ rec = {"kind": "miss-fix", "miss_id": mid, "fix_cmd": fix_cmd,
73
+ "fix_attempt": int(fa) if fa.isdigit() else 1,
74
+ "verdict_after": verdict, "reopened": False}
75
+ if started:
76
+ rec["fix_run_id"] = started
77
+ if emit("misses", rec):
78
+ closed += 1
79
+ print("%s: %d of %d miss-fix record(s) written (no run record — the amending command emits its own)"
80
+ % (fix_cmd, closed, len(direct)))
81
+ raise SystemExit(0)
82
+
83
+ met = q("--where") or "docs/metrics"
84
+ def jsonl(name):
85
+ try:
86
+ for line in open(os.path.join(met, name), encoding="utf-8", errors="replace"):
87
+ try: yield json.loads(line)
88
+ except Exception: pass
89
+ except OSError:
90
+ return
91
+ # The verdicts: every verify this fix chained, not only the last. docs/.last-verify.json holds one scoped
92
+ # verify, so a fix that verified a Phase 3 scope and then a --phase 1 scope read the Phase 3 rows as absent
93
+ # and wrote Needs re-verify on their misses (TF-052). The gate records each verify wrote since the fix
94
+ # started come first; the ledger fills in only when it belongs to this fix.
95
+ rows = {} # rid -> checklist status
96
+ if started:
97
+ for g in jsonl("gates.jsonl"):
98
+ if g.get("kind") == "gate" and g.get("app") in (None, app) and not g.get("backfilled") and g.get("gate") != "escaped" \
99
+ and (g.get("run_id") or "") >= started and g.get("req_id"):
100
+ rows[g["req_id"].upper()] = g.get("verdict")
42
101
  ledger = {}
43
102
  try:
44
103
  ledger = json.load(open(os.path.join("docs", ".last-verify.json"), encoding="utf-8"))
45
104
  except Exception:
46
105
  pass
47
- rows = ledger.get("rows") or {}
106
+ if not started or not ledger.get("run_id") or ledger["run_id"] >= started:
107
+ row_runs = ledger.get("row_runs") # a ledger kept row by row also holds older runs' rows (Lekhak TF-007)
108
+ for rid, v in (ledger.get("rows") or {}).items():
109
+ if started and isinstance(row_runs, dict) and rid not in (ledger.get("this_run") or []) \
110
+ and (row_runs.get(rid) or "") < started:
111
+ continue
112
+ # verdict_after takes a checklist status (SCHEMA §5.5): the ledger's verdict is mapped, never passed raw
113
+ # (MISS-TechieFlow-20260906-25: RENDER-FAIL was refused and twenty miss-fix records never landed)
114
+ rows.setdefault(rid.upper(), "Verified" if v == "PASS" else "FAIL" if v in ("FAIL", "BUILD-FAIL") else "Needs re-verify")
48
115
  reqs = [r.strip().upper() for r in os.environ["TF_REQS"].split(",") if r.strip()] or sorted(rows)
49
116
  subs = [s.strip() for s in os.environ["TF_SUBS"].split(",") if s.strip()]
50
117
  files = os.environ["TF_FILES"]
51
- run = {"kind": "run", "app": app, "cmd": "fix-issues", "mode": "fix", "ended": now, "reqs_touched": reqs, "reqs_count": len(reqs),
52
- "subagents": subs, "files_written": int(files) if files.isdigit() else len(reqs), "build_result": os.environ["TF_BUILD"]}
53
- if started: run["started"] = started
54
- # one run record per start: a close called twice must not count the fix twice
55
- runs_path = os.path.join(q("--where") or "docs/metrics", "runs.jsonl")
56
- already = False
57
- if started and os.path.isfile(runs_path):
58
- for line in open(runs_path, encoding="utf-8", errors="replace"):
59
- if '"cmd":"fix-issues"' in line and f'"started":"{started}"' in line:
60
- already = True
61
- break
62
- ok = True if already else emit("runs", run)
63
- if already:
64
- print(f"fix-issues: run record for start {started} already exists; not written again")
65
- closed, none = 0, []
118
+ # The fix's own time only: a run it chained (the inline verify) has its own record, so the fix is recorded
119
+ # in the gaps around it, the first gap keyed on the fix's start with the rows (its miss-fixes name that start).
120
+ inner, voided = [], set()
121
+ for r in jsonl("runs.jsonl"):
122
+ if r.get("kind") == "run-void":
123
+ voided.add((r.get("cmd"), r.get("started")))
124
+ elif r.get("kind", "run") == "run" and r.get("app") == app and r.get("cmd") != "fix-issues" and not r.get("backfilled") \
125
+ and started and (r.get("started") or "") >= started and (r.get("ended") or "") and r["ended"] <= now:
126
+ inner.append((r["started"], r["ended"], r.get("cmd")))
127
+ inner = sorted(w for w in inner if (w[2], w[0]) not in voided)
128
+ gaps, t = [], started
129
+ for s, e, _c in inner:
130
+ if t and s > t: gaps.append((t, s))
131
+ t = max(t, e) if t else e
132
+ if not started or not gaps or t < now: gaps.append((t, now))
133
+ existing = {r.get("started") for r in jsonl("runs.jsonl") if r.get("kind", "run") == "run" and r.get("cmd") == "fix-issues"}
134
+ wrote = had = 0
135
+ for i, (s, e) in enumerate(gaps):
136
+ first = i == 0
137
+ run = {"kind": "run", "app": app, "cmd": "fix-issues", "mode": "fix", "ended": e, "reqs_touched": reqs if first else [],
138
+ "reqs_count": len(reqs) if first else 0, "subagents": subs if first else [],
139
+ "files_written": (int(files) if files.isdigit() else len(reqs)) if first else 0, "build_result": os.environ["TF_BUILD"]}
140
+ if s: run["started"] = s
141
+ # one run record per start: a close called twice must not count the fix twice
142
+ if s and s in existing:
143
+ had += 1; continue
144
+ wrote += emit("runs", run)
145
+ if inner:
146
+ print(f"fix-issues: recorded around {len(inner)} chained run(s) ({', '.join(sorted({c for _s, _e, c in inner}))}) in {len(gaps)} segment(s)")
147
+ closed, none, unverified = 0, [], []
66
148
  for rid in reqs:
149
+ if rid not in rows:
150
+ unverified.append(rid); continue
67
151
  opened = q("--open-miss", rid)
68
152
  if not opened:
69
153
  none.append(rid); continue
70
154
  mid = opened.split()[0]
71
155
  fa = q("--next-fix-attempt", mid) or "1"
72
- v = rows.get(rid)
73
- # verdict_after takes a checklist status (SCHEMA §5.5): the ledger's verdict is mapped, never passed raw
74
- # (MISS-TechieFlow-20260906-25: RENDER-FAIL was refused and twenty miss-fix records never landed)
75
- after = ("Verified" if v == "PASS" else "FAIL" if v in ("FAIL", "BUILD-FAIL") else "Needs re-verify")
76
156
  rec = {"kind": "miss-fix", "miss_id": mid, "req_id": rid, "fix_cmd": "fix-issues", "fix_attempt": int(fa) if fa.isdigit() else 1,
77
- "verdict_after": after, "reopened": False}
157
+ "verdict_after": rows[rid], "reopened": False}
78
158
  if started: rec["fix_run_id"] = started
79
159
  if emit("misses", rec): closed += 1
80
- print(f"fix-issues: run record {'already there' if already else 'written' if ok else 'not written'}; {closed} miss-fix record(s) with the verifier's verdict"
81
- + (f"; no open miss on {', '.join(none)} (log it first, then re-run)" if none else ""))
160
+ state = "already there" if had == len(gaps) else "written" if wrote + had == len(gaps) else "not written"
161
+ print(f"fix-issues: run record {state}; {closed} miss-fix record(s) with the verifier's verdict"
162
+ + (f"; no open miss on {', '.join(none)} (log it first, then re-run)" if none else "")
163
+ + (f"; no verify graded {', '.join(unverified)} during this fix, so its miss stays open with no miss-fix" if unverified else ""))
82
164
  PY
83
165
  exit 0
@@ -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
@@ -56,14 +68,19 @@
56
68
  # Override command lines with TF_GOAL_CLAUDE_FLAGS / TF_GOAL_OPENCODE_FLAGS /
57
69
 
58
70
  set -u
71
+ # GNU-only commands (setsid, stat -c, date -d) go through the shim, so a stock Mac runs this too.
72
+ source "$(dirname "${BASH_SOURCE[0]}")/tf-portable.sh"
59
73
 
60
74
  HARNESS="claude"; MODEL=""; BUFFER_MIN=15; DEFAULT_WAIT_MIN=60; MAX_CYCLES=60; IDLE_RETRY=30
61
75
  PROBE_MIN=15; PROBE_MAX_H=8; STALL_MIN=15
76
+ TIER="standard"; FALLBACK=1
62
77
  RESUME=0; FRESH=0; DRY=0
63
78
  while [[ $# -gt 0 ]]; do
64
79
  case "$1" in
65
80
  --harness) HARNESS="$2"; shift 2 ;;
66
81
  --model) MODEL="$2"; shift 2 ;;
82
+ --tier) TIER="$2"; shift 2 ;;
83
+ --no-fallback) FALLBACK=0; shift ;;
67
84
  --buffer-min) BUFFER_MIN="$2"; shift 2 ;;
68
85
  --default-wait-min) DEFAULT_WAIT_MIN="$2"; shift 2 ;;
69
86
  --probe-min) PROBE_MIN="$2"; shift 2 ;;
@@ -74,7 +91,7 @@ while [[ $# -gt 0 ]]; do
74
91
  --resume) RESUME=1; shift ;;
75
92
  --fresh) FRESH=1; shift ;;
76
93
  --dry-run) DRY=1; shift ;;
77
- -h|--help) sed -n '2,45p' "$0"; exit 0 ;;
94
+ -h|--help) sed -n '2,48p' "$0"; exit 0 ;;
78
95
  --) shift; break ;;
79
96
  -*) echo "unknown option $1" >&2; exit 2 ;;
80
97
  *) break ;;
@@ -88,11 +105,15 @@ fi
88
105
  APP_DIR="$(cd "$APP_DIR" 2>/dev/null && pwd)" || { echo "no such dir: $1" >&2; exit 2; }
89
106
  [[ -d "$APP_DIR/.tfcore" ]] || { echo "$APP_DIR has no .tfcore/ — scaffold it first" >&2; exit 2; }
90
107
  case "$HARNESS" in claude|opencode) ;; *) echo "--harness must be claude|opencode" >&2; exit 2 ;; esac
108
+ case "$TIER" in frontier|standard|economy) ;; *) echo "--tier must be frontier|standard|economy" >&2; exit 2 ;; esac
91
109
  command -v python3 >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 2; }
92
110
 
93
111
  STATE_DIR="$APP_DIR/.tfcore/.session"; mkdir -p "$STATE_DIR"
94
112
  STATE="$STATE_DIR/goal.json"; LOG="$STATE_DIR/goal.log"; DONE="$STATE_DIR/goal-done.json"
95
113
  YOLO_SH="$APP_DIR/.tfcore/utils/tf-yolo.sh"
114
+ PICK_SH="$APP_DIR/.tfcore/utils/tf-model-pick.sh"
115
+ BIND_SH="$APP_DIR/.tfcore/utils/tf-routing-bind.sh"
116
+ [[ -f "$PICK_SH" ]] || FALLBACK=0 # a project that predates tf-model-pick.sh keeps the old behaviour
96
117
 
97
118
  if [[ "$GOAL_ARG" == @* ]]; then
98
119
  GOAL_FILE="${GOAL_ARG#@}"; [[ -f "$GOAL_FILE" ]] || { echo "goal file not found: $GOAL_FILE" >&2; exit 2; }
@@ -126,6 +147,10 @@ if [[ $RESUME -eq 1 ]]; then
126
147
  [[ -f "$STATE" ]] || { echo "--resume: no $STATE" >&2; exit 2; }
127
148
  [[ -z "$GOAL" ]] && GOAL="$(state_get goal)"
128
149
  HARNESS="$(state_get harness)"; HARNESS="${HARNESS:-claude}"
150
+ # A resume keeps the model a fallback moved to, and the tier its chain came from; an explicit
151
+ # --model on the resume command still wins.
152
+ _t="$(state_get tier)"; [[ -n "$_t" ]] && TIER="$_t"
153
+ _m="$(state_get model)"; [[ -n "$_m" && -z "$MODEL" ]] && MODEL="$_m"
129
154
  CYCLE="$(state_get cycle)"; CYCLE="${CYCLE:-0}"
130
155
  SESSION_ID="$(state_get session_id)"
131
156
  STALLS="$(state_get stalls)"; STALLS="${STALLS:-0}"
@@ -139,12 +164,12 @@ else
139
164
  _lr="$(state_get last_reason)"
140
165
  case "$_lr" in
141
166
  done:*|max-cycles|stopped|"") ;;
142
- *) echo "tf-goal: a run looks active in $APP_DIR (goal.json last_reason=$_lr, touched $(date -u -r "$STATE" +%H:%MZ)). Wait for it, or continue it with --resume." >&2; exit 2 ;;
167
+ *) echo "tf-goal: a run looks active in $APP_DIR (goal.json last_reason=$_lr, touched $(tf_date_from -u "@$(tf_stat_mtime "$STATE")" +%H:%MZ)). Wait for it, or continue it with --resume." >&2; exit 2 ;;
143
168
  esac
144
169
  fi
145
170
  CYCLE=0; SESSION_ID=""; STALLS=0
146
171
  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"
172
+ state_set goal "$GOAL" harness "$HARNESS" tier "$TIER" model "$MODEL" cycle 0 session_id "" stalls 0 started "$(ts)" last_reason "start"
148
173
  rm -f "$DONE"
149
174
  fi
150
175
  fi
@@ -346,6 +371,60 @@ print(sid)
346
371
  PY
347
372
  }
348
373
 
374
+ extract_model() { # the model the harness reported for this cycle, from its own output
375
+ python3 - "$1" <<'PY' 2>/dev/null
376
+ import sys, json, re
377
+ m = ""
378
+ try:
379
+ for line in open(sys.argv[1], errors="replace"):
380
+ line = line.strip()
381
+ if not line.startswith("{") or '"model"' not in line:
382
+ continue
383
+ try: d = json.loads(line)
384
+ except Exception: continue
385
+ if not isinstance(d, dict): continue
386
+ if isinstance(d.get("model"), str) and d["model"]:
387
+ m = d["model"]
388
+ msg = d.get("message")
389
+ if isinstance(msg, dict) and isinstance(msg.get("model"), str) and msg["model"]:
390
+ m = msg["model"]
391
+ except Exception:
392
+ pass
393
+ print(m)
394
+ PY
395
+ }
396
+
397
+ # Park the model this cycle ran on and move to the next one in its tier. Prints nothing and
398
+ # returns 1 when there is nowhere to go — the caller then sleeps, which is what it always did.
399
+ # $1 = epoch the limit is stated to reset at (or empty when it could not be parsed)
400
+ # $2 = why, for the cooldown record
401
+ try_fallback() {
402
+ [[ $FALLBACK -eq 1 ]] || return 1
403
+ local until="${1:-}" why="${2:-usage limit}" cur next
404
+ cur="$MODEL"
405
+ [[ -z "$cur" ]] && cur="$(extract_model "$OUT")"
406
+ if [[ -z "$cur" ]]; then
407
+ cur="$(TF_PROJECT_DIR="$APP_DIR" bash "$PICK_SH" pick "$TIER" "$HARNESS" 2>/dev/null | tail -1)"
408
+ [[ "$cur" == inherit ]] && cur=""
409
+ fi
410
+ if [[ -z "$cur" ]]; then
411
+ 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)"
412
+ return 1
413
+ fi
414
+ [[ -z "$until" ]] && until="probe"
415
+ TF_PROJECT_DIR="$APP_DIR" bash "$PICK_SH" cooldown "$HARNESS" "$cur" "$until" "$why" >>"$LOG" 2>&1
416
+ next="$(TF_PROJECT_DIR="$APP_DIR" bash "$PICK_SH" pick "$TIER" "$HARNESS" 2>/dev/null | tail -1)"
417
+ if [[ -z "$next" || "$next" == "inherit" ]]; then
418
+ log "no fallback left: every model in the $TIER chain is on cooldown — waiting for the reset instead"
419
+ return 1
420
+ fi
421
+ MODEL="$next"
422
+ state_set model "$MODEL" fallback_from "$cur" last_reason "fallback:$cur->$MODEL"
423
+ log "FALLBACK: $cur is limited ($why) — the next cycle runs on $MODEL (tier $TIER). Re-binding so the sub-agents move too."
424
+ [[ -f "$BIND_SH" ]] && bash "$BIND_SH" "$APP_DIR" 2>&1 | sed 's/^/ /' | tee -a "$LOG" >&2
425
+ return 0
426
+ }
427
+
349
428
  # Fallback when a limit message carries no parseable reset time: fire a one-turn
350
429
  # probe every PROBE_MIN minutes until it comes back clean (exit 0 AND no limit
351
430
  # wording in its output). Gives up after PROBE_MAX_H hours and resumes anyway.
@@ -359,11 +438,11 @@ probe_until_clear() {
359
438
  else
360
439
  ( cd "$APP_DIR" && opencode run --auto "Reply with the single word OK." ) > "$pout" 2>&1; prc=$?
361
440
  fi
362
- if [[ $prc -eq 0 ]] && ! grep -qiE 'usage limit|hit your limit|rate[ _-]?limit|limit (has been )?(reached|exceeded)|too many requests|\b429\b|overloaded|weekly limit|resets? (at|in)\b' "$pout"; then
441
+ if [[ $prc -eq 0 ]] && ! grep -qiE 'usage limit|hit your limit|rate[ _-]?limit|limit (has been )?(reached|exceeded)|too many requests|(^|[^[:alnum:]_])429([^[:alnum:]_]|$)|overloaded|weekly limit|resets? (at|in)([^[:alnum:]_]|$)' "$pout"; then
363
442
  log "probe #$n OK"; return 0
364
443
  fi
365
444
  log "probe #$n still limited (rc=$prc: $(head -c 120 "$pout" | tr '\n' ' ')) — next in ${PROBE_MIN}m"
366
- state_set resume_at "probe #$((n+1)) at $(date -d "+${PROBE_MIN} min" '+%H:%M' 2>/dev/null)"
445
+ state_set resume_at "probe #$((n+1)) at $(tf_date_from "+${PROBE_MIN} min" '+%H:%M' 2>/dev/null)"
367
446
  if [[ $(date +%s) -ge $deadline ]]; then log "probe window (${PROBE_MAX_H}h) exhausted — resuming anyway"; return 1; fi
368
447
  nap $(( PROBE_MIN * 60 ))
369
448
  done
@@ -373,7 +452,7 @@ sleep_until() { # epoch
373
452
  local target="$1" now left
374
453
  while :; do
375
454
  now=$(date +%s); left=$(( target - now )); [[ $left -le 0 ]] && break
376
- state_set resume_at "$(date -u -d "@$target" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "$target")"
455
+ state_set resume_at "$(tf_date_from -u "@$target" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "$target")"
377
456
  if [[ $left -gt 300 ]]; then nap 300; else nap "$left"; fi
378
457
  done
379
458
  state_set resume_at ""
@@ -405,16 +484,14 @@ run_cycle() { # runs "${CMD[@]}" in $APP_DIR, output to $OUT and $LOG; sets RC,
405
484
  STALLED=0
406
485
  local quiet=0 prev=-1 size
407
486
  if [[ -n "${TF_GOAL_FAKE_CMD:-}" ]]; then CMD=(bash -c "$TF_GOAL_FAKE_CMD"); fi
408
- if command -v setsid >/dev/null 2>&1; then
409
- ( cd "$APP_DIR" && TF_YOLO=1 exec setsid "${CMD[@]}" ) > >(tee -a "$LOG" > "$OUT") 2>&1 &
410
- else
411
- ( cd "$APP_DIR" && TF_YOLO=1 exec "${CMD[@]}" ) > >(tee -a "$LOG" > "$OUT") 2>&1 &
412
- fi
487
+ # Its own session and process group (tf_setsid: setsid(1), or python3/perl on a Mac), so
488
+ # kill_child can stop the whole tree.
489
+ ( cd "$APP_DIR" && TF_YOLO=1 tf_setsid --exec "${CMD[@]}" ) > >(tee -a "$LOG" > "$OUT") 2>&1 &
413
490
  CHILD=$!
414
491
  while kill -0 "$CHILD" 2>/dev/null; do
415
492
  nap "$STALL_TICK"
416
493
  kill -0 "$CHILD" 2>/dev/null || break
417
- size="$(stat -c %s "$OUT" 2>/dev/null || echo 0)"
494
+ size="$(tf_stat_size "$OUT" 2>/dev/null || echo 0)"
418
495
  if [[ "$size" == "$prev" ]]; then quiet=$(( quiet + STALL_TICK )); else quiet=0; prev="$size"; fi
419
496
  if [[ "$STALL_SEC" -gt 0 && $quiet -ge "$STALL_SEC" ]]; then
420
497
  log "STALL: no output for $(( quiet / 60 ))m$(( quiet % 60 ))s (cycle $CYCLE, ${size} bytes) — stopping the harness, re-prompting"
@@ -492,7 +569,7 @@ while :; do
492
569
  OUT="$STATE_DIR/goal-cycle-$CYCLE.out"
493
570
  if [[ $DRY -eq 1 ]]; then echo "dry run — would launch cycle $CYCLE ($KIND):" >&2; printf ' %q' "${CMD[@]}"; echo; exit 0; fi
494
571
  state_set cycle "$CYCLE"; CYCLE_START="$(date +%s)"
495
- log "cycle $CYCLE ($KIND) → ${CMD[*]:0:6} … (prompt ${#CMD[-1]} chars)"
572
+ log "cycle $CYCLE ($KIND) → ${CMD[*]:0:6} … (prompt ${#CMD[${#CMD[@]}-1]} chars)"
496
573
 
497
574
  # The run's start is now, not when the agent reaches step 0: write an unclaimed marker the
498
575
  # first command's `tf-phase.sh start` claims (MISS-TechieFlow-20260905-20).
@@ -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
@@ -533,7 +620,7 @@ while :; do
533
620
  log "probe succeeded — resuming session ${SESSION_ID:-(--continue)} after a ${BUFFER_MIN}m buffer"
534
621
  nap $(( BUFFER_MIN * 60 ))
535
622
  else
536
- WHEN="$(date -d "@$DETAIL" '+%Y-%m-%d %H:%M %Z' 2>/dev/null || echo "$DETAIL")"
623
+ WHEN="$(tf_date_from "@$DETAIL" '+%Y-%m-%d %H:%M %Z' 2>/dev/null || echo "$DETAIL")"
537
624
  log "USAGE LIMIT hit (cycle $CYCLE) — RETRY AT $WHEN (stated reset + ${BUFFER_MIN}m buffer). Sleeping."
538
625
  state_set last_reason "limit" resume_at "$WHEN"
539
626
  sleep_until "$DETAIL"
@@ -48,13 +48,18 @@ _detect() {
48
48
  fi
49
49
  local k
50
50
  for k in $(compgen -v | grep '^OPENCODE' 2>/dev/null); do printf 'opencode'; return; done
51
- # /proc ancestry walk (Linux/WSL); bounded
51
+ # ancestry walk, bounded: /proc on Linux/WSL, ps where there is no /proc (macOS)
52
52
  local pid=$PPID name ppid seen=0 stat
53
53
  while [[ -n "$pid" && "$pid" -gt 1 && $seen -lt 12 ]]; do
54
54
  seen=$((seen + 1))
55
- stat="$(cat /proc/$pid/stat 2>/dev/null)" || break
56
- name="${stat#*(}"; name="${name%%)*}"
57
- ppid="$(printf '%s' "${stat##*) }" | awk '{print $2}')"
55
+ if [[ -d /proc/self ]]; then
56
+ stat="$(cat /proc/$pid/stat 2>/dev/null)" || break
57
+ name="${stat#*(}"; name="${name%%)*}"
58
+ ppid="$(printf '%s' "${stat##*) }" | awk '{print $2}')"
59
+ else
60
+ ppid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')"; [[ -n "$ppid" ]] || break
61
+ name="$(ps -o comm= -p "$pid" 2>/dev/null)"; name="${name##*/}"
62
+ fi
58
63
  case "$name" in
59
64
  *opencode*) printf 'opencode'; return ;;
60
65
  *claude*) printf 'claude-code'; return ;;
@@ -0,0 +1,29 @@
1
+ # tf-lock.sh — one build or browser check at a time per repository. Sourced, not run.
2
+ # source "$HERE/tf-lock.sh"; tf_take_lock <label> waits for the lock, then holds it until exit
3
+ # A directory, because mkdir is atomic on every drive this runs on. A lock whose process has gone
4
+ # is taken over. A script started by the holder (tf-verify-tests.sh running tf-build.sh) shares it
5
+ # through TF_REPO_LOCK_PID. TF_BUILD_LOCK_MAX seconds to wait (1800). (TF-043, TfLens TF-048)
6
+ TF_LOCK="tests/.artifacts/build/.lock"
7
+ tf_release_lock() { [[ "$(cut -d' ' -f1 "$TF_LOCK/owner" 2>/dev/null)" == "$$" ]] && rm -rf "$TF_LOCK"; }
8
+ tf_take_lock() {
9
+ local label="${1:-}" waited=0 max="${TF_BUILD_LOCK_MAX:-1800}" said=0 owner opid ohost
10
+ if [[ -n "${TF_REPO_LOCK_PID:-}" ]] && kill -0 "$TF_REPO_LOCK_PID" 2>/dev/null; then return 0; fi
11
+ mkdir -p "$(dirname "$TF_LOCK")"
12
+ while ! mkdir "$TF_LOCK" 2>/dev/null; do
13
+ owner="$(cat "$TF_LOCK/owner" 2>/dev/null)"; opid="$(cut -d' ' -f1 <<<"$owner")"; ohost="$(cut -d' ' -f2 <<<"$owner")"
14
+ if [[ -z "$owner" ]]; then
15
+ [[ -n "$(find "$TF_LOCK" -maxdepth 0 -mmin +1 2>/dev/null)" ]] && { rm -rf "$TF_LOCK"; continue; }
16
+ elif [[ "$ohost" == "$(hostname)" ]] && ! kill -0 "$opid" 2>/dev/null; then
17
+ rm -rf "$TF_LOCK"; continue
18
+ fi
19
+ [[ $said -eq 0 ]] && { echo "wait another build or browser check is running in this repository (${owner:-starting}); this one starts when it finishes" >&2; said=1; }
20
+ if [[ $waited -ge $max ]]; then
21
+ echo "NOT-RUN another build or browser check held this repository for $((max / 60)) minutes ($owner). If nothing is running, remove $TF_LOCK and run again. Not a code error"
22
+ exit 2
23
+ fi
24
+ sleep 3; waited=$((waited + 3))
25
+ done
26
+ echo "$$ $(hostname) $label $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$TF_LOCK/owner"
27
+ export TF_REPO_LOCK_PID=$$
28
+ trap tf_release_lock EXIT
29
+ }