@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
@@ -19,6 +19,10 @@
19
19
  # tf-emit.sh --amend MISS-App-20260828-01 why_missed missing-checklist-item
20
20
  # # fills a field that is still null on a miss already on
21
21
  # # the stream (SCHEMA.md §5.5.7). Never overwrites.
22
+ # tf-emit.sh --void-run build-phase 2026-09-09T11:40:00Z "the start time was guessed, not measured"
23
+ # # marks a WRONG run record as not to be counted: both
24
+ # # records stay, every figure skips it and says how many
25
+ # # and why (SCHEMA.md §2.7). Refuses if no such run.
22
26
  # tf-emit.sh --origin-of REQ-UI-014 # prints "<started> <cmd> <agent>" of the last build or fix
23
27
  # # run that touched the row, nothing when there is none
24
28
  # tf-emit.sh --where # prints the resolved docs/metrics dir
@@ -270,6 +274,61 @@ fi
270
274
  # patience rather than quality. Open = no miss-fix, or the latest miss-fix
271
275
  # closed with a verdict_after other than "Verified". Prints "<miss_id>
272
276
  # <miss_class>" for the most recent open miss, or nothing at all.
277
+ # --- list the open misses whose deficient artifact is a document ----------
278
+ # `*amend-docs` is the command that edits a BRD, an Architecture or a mockup, so it is
279
+ # the only command that can close a miss whose artifact is one of those -- and it had no
280
+ # way to find them. Every other close path works off a checklist row the verifier touched,
281
+ # which a BRD edit never is, so a document miss stayed open forever (TF-016: twelve
282
+ # finished TfLens items showed as outstanding for up to two weeks).
283
+ #
284
+ # tf-emit.sh --open-misses <App> [--artifact-class doc]
285
+ #
286
+ # Prints one line per open miss: "<miss_id> <artifact> <what>". Open means no miss-fix
287
+ # carries verdict_after Verified, the same test --open-miss applies.
288
+ if [[ "$1" == "--open-misses" ]]; then
289
+ OMAPP="${2:-}"; OMCLASS=""
290
+ [[ "${3:-}" == "--artifact-class" ]] && OMCLASS="${4:-}"
291
+ TF_OMAPP="$OMAPP" TF_OMCLASS="$OMCLASS" python3 - "$MET_DIR/misses.jsonl" 2>"$TF_ERR" <<'PY2' || true
292
+ import json, os, sys
293
+ DOC = {"brd", "architecture", "uidesign", "checklist", "devguide"}
294
+ app, klass = os.environ.get("TF_OMAPP") or "", os.environ.get("TF_OMCLASS") or ""
295
+ opened, fixes = [], {}
296
+ try:
297
+ with open(sys.argv[1], encoding="utf-8") as fh:
298
+ for line in fh:
299
+ line = line.strip()
300
+ if not line:
301
+ continue
302
+ try:
303
+ r = json.loads(line)
304
+ except Exception:
305
+ continue
306
+ if r.get("kind") == "miss":
307
+ opened.append(r)
308
+ elif r.get("kind") == "miss-fix":
309
+ mid = r.get("miss_id")
310
+ if mid:
311
+ prev = fixes.get(mid)
312
+ if prev is None or (r.get("ts") or "") >= (prev.get("ts") or ""):
313
+ fixes[mid] = r
314
+ except FileNotFoundError:
315
+ pass
316
+ for r in opened:
317
+ if app and r.get("app") and r.get("app") != app:
318
+ continue
319
+ art = r.get("artifact") or ""
320
+ if klass == "doc" and art not in DOC:
321
+ continue
322
+ if klass and klass != "doc" and art != klass:
323
+ continue
324
+ fix = fixes.get(r.get("miss_id"))
325
+ if fix is not None and fix.get("verdict_after") == "Verified":
326
+ continue
327
+ print("%s %s %s" % (r.get("miss_id"), art or "-", (r.get("what") or "")[:110]))
328
+ PY2
329
+ exit 0
330
+ fi
331
+
273
332
  if [[ "$1" == "--open-miss" ]]; then
274
333
  REQ="$2"
275
334
  [[ -n "$REQ" ]] || exit 0
@@ -344,9 +403,19 @@ AMENDABLE = {
344
403
  # still make later, so a miss logged before the field existed can be sorted now.
345
404
  "sort": ("spec", "unsaid", "weak-check", "ignored"),
346
405
  }
347
- if fld not in AMENDABLE:
406
+ # `what` is the miss's own sentence, and it is FREE TEXT -- the one field here that
407
+ # is not a closed vocabulary. It is allowed because the protection was never the
408
+ # vocabulary: an amend may only fill a field that is still null and can never
409
+ # overwrite one that is set (below), so free text cannot rewrite a fact, only
410
+ # supply one that was missing. Validated as non-empty text instead.
411
+ # MISS-TechieFlow-20260909-02; tests/regression/run.sh tf_018.
412
+ AMENDABLE_TEXT = ("what",)
413
+ if fld not in AMENDABLE and fld not in AMENDABLE_TEXT:
348
414
  print("REFUSED %s is not an amendable field (SCHEMA.md §5.5.7)" % fld); raise SystemExit(0)
349
- if val not in AMENDABLE[fld]:
415
+ if fld in AMENDABLE_TEXT:
416
+ if not isinstance(val, str) or not val.strip():
417
+ print("REFUSED %s must be a non-empty sentence" % fld); raise SystemExit(0)
418
+ elif val not in AMENDABLE[fld]:
350
419
  print("REFUSED %r is not in the closed vocabulary for %s" % (val, fld)); raise SystemExit(0)
351
420
 
352
421
  parent, current = None, None
@@ -389,8 +458,77 @@ PY
389
458
  exit 0
390
459
  fi
391
460
 
461
+ # --- write helper: mark a run record as not to be counted -----------------
462
+ # SCHEMA.md §2.7. The same doctrine as --amend, one stream over: a run record that
463
+ # is WRONG cannot be edited or deleted, because the stream is append-only and the
464
+ # evidence that it happened is worth keeping. So the correction is another record.
465
+ #
466
+ # tf-emit.sh --void-run <cmd> <started> "<why it is wrong>"
467
+ #
468
+ # It names the run by its own `cmd` and `started` — the pair every run record
469
+ # carries and the key the readers already use — and every figure then skips it,
470
+ # with the count and the reasons printed. A void that names no run on this stream
471
+ # is refused: it would be a claim about nothing. It is deliberately NOT a delete:
472
+ # both records stay, and a reader can see what was corrected and why.
473
+ if [[ "$1" == "--void-run" ]]; then
474
+ VCMD="$2"; VSTART="$3"; VWHY="$4"
475
+ if [[ -z "$VCMD" || -z "$VSTART" || -z "$VWHY" ]]; then
476
+ echo "tf-emit: --void-run needs <cmd> <started> \"<reason>\""
477
+ exit 0
478
+ fi
479
+ VREC="$(python3 - "$MET_DIR/runs.jsonl" "$VCMD" "$VSTART" "$VWHY" 2>"$TF_ERR" <<'PY' || true
480
+ import json, sys
481
+ path, cmd, started, why = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
482
+ target, voided, app = None, False, None
483
+ try:
484
+ with open(path, encoding="utf-8") as fh:
485
+ for line in fh:
486
+ line = line.strip()
487
+ if not line:
488
+ continue
489
+ try:
490
+ r = json.loads(line)
491
+ except Exception:
492
+ continue
493
+ if r.get("cmd") == cmd and r.get("started") == started:
494
+ if r.get("kind") == "run-void":
495
+ voided = True
496
+ elif r.get("kind", "run") == "run":
497
+ target = r
498
+ app = r.get("app")
499
+ except FileNotFoundError:
500
+ pass
501
+
502
+ if target is None:
503
+ print("REFUSED no run record %s started %s on this stream" % (cmd, started)); raise SystemExit(0)
504
+ if voided:
505
+ print("REFUSED %s started %s is already voided — one void is enough" % (cmd, started)); raise SystemExit(0)
506
+ if not why.strip():
507
+ print("REFUSED a void needs a reason a reader can check"); raise SystemExit(0)
508
+
509
+ print("OK " + json.dumps({"kind": "run-void", "app": app, "cmd": cmd,
510
+ "started": started, "reason": why.strip()},
511
+ separators=(",", ":"), ensure_ascii=False))
512
+ PY
513
+ )"
514
+ case "$VREC" in
515
+ "OK "*) printf '%s' "${VREC#OK }" | bash "${BASH_SOURCE[0]}" runs
516
+ echo "tf-emit: voided $VCMD started $VSTART — $VWHY"
517
+ echo " both records stay on the stream; every figure now skips the run and says so" ;;
518
+ "REFUSED "*) echo "tf-emit: void refused — ${VREC#REFUSED }" ;;
519
+ *) echo "tf-emit: void could not be evaluated — nothing written" ;;
520
+ esac
521
+ exit 0
522
+ fi
523
+
392
524
  # --- append path ----------------------------------------------------------
393
525
  STREAM="$1"
526
+ # --allow-overlap: this run really did happen while another was still open (a second machine
527
+ # appending to the same merge=union stream is the only case that has ever come up). Without it a
528
+ # run whose `started` precedes the previous run's `ended` is refused — see _overlap_ok below.
529
+ TF_ALLOW_OVERLAP=0
530
+ for _a in "$@"; do [[ "$_a" == "--allow-overlap" ]] && TF_ALLOW_OVERLAP=1; done
531
+ export TF_ALLOW_OVERLAP
394
532
  case "$STREAM" in
395
533
  runs|gates|sessions|commits|misses) ;;
396
534
  *) _warn "unknown stream '${STREAM:-<none>}' — event dropped"; exit 0 ;;
@@ -399,7 +537,7 @@ esac
399
537
  # The program is captured into a variable rather than fed on stdin — a heredoc
400
538
  # on `python3 -` would REPLACE the caller's piped JSON and silently drop every event.
401
539
  TF_PROG="$(cat <<'PY'
402
- import json, os, sys, datetime, glob, re, time
540
+ import json, os, sys, datetime, glob, re, subprocess, time
403
541
 
404
542
  met_dir, stream, root = sys.argv[1], sys.argv[2], sys.argv[3]
405
543
  dbg = os.environ.get("TF_METRICS_DEBUG")
@@ -597,15 +735,41 @@ def enrich(rec):
597
735
  if rec.get("kind") == "run" and isinstance(rec.get("ended"), str):
598
736
  t_end, t_now = _iso_ms(rec["ended"]), _iso_ms(NOW)
599
737
  t_start = _iso_ms(rec["started"]) if isinstance(rec.get("started"), str) else None
600
- if t_end is not None and t_now is not None and (t_end > t_now + 60_000 or (t_start is not None and t_end < t_start)):
601
- sys.stdout.write("tf-emit: ended %s is not a measurement (it lies %s); set to now, %s\n"
602
- % (rec["ended"], "in the future" if t_end > t_now else "before started", NOW))
738
+ # Two different faults, and they must not share a remedy.
739
+ #
740
+ # (a) `ended` a little in the future: `started` is still trustworthy, so clamp to
741
+ # now and re-derive (MISS-TechieFlow-20260905-11, a day-1 run wrote 17:05 at 16:40).
742
+ # (b) `ended` BEFORE `started`: the record contradicts itself and there is no way to
743
+ # tell which of the two is wrong, so it carries NO duration at all. Deriving one
744
+ # from the surviving timestamp invents a figure -- a TechieBlog record started
745
+ # 2026-08-22 would have been "measured" at 1,515,038 seconds on the day it was
746
+ # re-emitted. An honest gap beats a plausible number: SCHEMA §2.5.
747
+ if t_end is not None and t_now is not None and t_start is not None and t_end < t_start:
748
+ sys.stdout.write("tf-emit: ended %s precedes started %s; the record contradicts "
749
+ "itself, so no duration is stored\n" % (rec["ended"], rec["started"]))
750
+ rec["ended"] = NOW
751
+ rec.pop("duration_s", None)
752
+ rec["duration_unmeasured"] = "ended-before-started"
753
+ elif t_end is not None and t_now is not None and t_end > t_now + 60_000:
754
+ sys.stdout.write("tf-emit: ended %s is not a measurement (it lies in the future); "
755
+ "set to now, %s\n" % (rec["ended"], NOW))
603
756
  rec["ended"] = NOW
604
757
  if t_start is not None and "duration_s" in rec:
605
758
  rec["duration_s"] = max(0, (t_now - t_start) // 1000)
759
+ elif t_end is not None and t_start is not None and isinstance(rec.get("duration_s"), (int, float)):
760
+ # A supplied duration that bears no relation to its own timestamps. Thirteen of
761
+ # TechieBlog's fourteen impossible records hid here, storing a plausible round
762
+ # number (3600, 2700, 1800, 900, 600) that nothing downstream could question --
763
+ # only the ONE record that stored the negative arithmetic was ever detectable.
764
+ span = (t_end - t_start) // 1000
765
+ if abs(span - rec["duration_s"]) > 1:
766
+ sys.stdout.write("tf-emit: duration_s %s disagrees with started/ended (%ss); "
767
+ "using the timestamps\n" % (rec["duration_s"], span))
768
+ rec["duration_s"] = span
606
769
  # A run record that omits duration_s gets it from started and ended (the devguide and
607
770
  # refresh-status records of 2026-09-06 had none; MISS-TechieFlow-20260906-10).
608
- if rec.get("kind") == "run" and "duration_s" not in rec and rec.get("started") and rec.get("ended"):
771
+ if rec.get("kind") == "run" and "duration_s" not in rec and rec.get("started") and rec.get("ended") \
772
+ and not rec.get("duration_unmeasured"):
609
773
  try:
610
774
  rec["duration_s"] = max(0, (_iso_ms(rec["ended"]) - _iso_ms(rec["started"])) // 1000)
611
775
  except Exception:
@@ -663,6 +827,52 @@ def _routing():
663
827
 
664
828
  ROUTING = _routing()
665
829
 
830
+ # --- how a model is paid for (SCHEMA.md §2.5b, added 2026-09-10) -----------
831
+ # One cost column cannot mean three things at once. A run on a flat-fee subscription records
832
+ # zero marginal dollars, a run on a metered API key records real money, and a run on a local
833
+ # model records zero because there is no bill — and until this field existed a reader could
834
+ # not tell those three zeroes apart, so every "what did it cost" figure quietly averaged real
835
+ # spending with free runs. The answer is resolved by tf-model-pick.sh, which reads OpenCode's
836
+ # own auth.json and model catalog, so it is looked up and never declared by an agent — the
837
+ # same rule as `harness` (§1) and `origin_model` (§5.5.1).
838
+ _BILLING_CACHE = {}
839
+
840
+
841
+ def _billing(model, harness):
842
+ key = (model, harness)
843
+ if key not in _BILLING_CACHE:
844
+ mode, source = "unknown", "none"
845
+ pick = os.path.join(root, ".tfcore", "utils", "tf-model-pick.sh")
846
+ if model and os.path.isfile(pick):
847
+ try:
848
+ out = subprocess.run(["bash", pick, "billing", model, harness or "claude"],
849
+ capture_output=True, text=True, timeout=20,
850
+ env=dict(os.environ, TF_PROJECT_DIR=root))
851
+ parts = (out.stdout or "").strip().split("\t")
852
+ if parts and parts[0] in ("subscription", "plan", "metered", "local", "unknown"):
853
+ mode = parts[0]
854
+ source = parts[1] if len(parts) > 1 else "tf-model-pick"
855
+ except Exception:
856
+ pass
857
+ _BILLING_CACHE[key] = (mode, source)
858
+ return _BILLING_CACHE[key]
859
+
860
+
861
+ def _was_limited(model, harness, at_epoch):
862
+ """Did this run start while the tier's own model was parked? Then it is a deliberate
863
+ fallback, not routing drift, and `routed:false` should not be read as one."""
864
+ pick = os.path.join(root, ".tfcore", "utils", "tf-model-pick.sh")
865
+ if not model or not os.path.isfile(pick) or at_epoch is None:
866
+ return False
867
+ try:
868
+ out = subprocess.run(["bash", pick, "was-limited", harness or "claude", model, str(int(at_epoch))],
869
+ capture_output=True, text=True, timeout=20,
870
+ env=dict(os.environ, TF_PROJECT_DIR=root))
871
+ return out.returncode == 0
872
+ except Exception:
873
+ return False
874
+
875
+
666
876
  def _iso_ms(s):
667
877
  try:
668
878
  return int(datetime.datetime.strptime(
@@ -836,6 +1046,97 @@ def _prior_runs():
836
1046
  pass
837
1047
  return _PRIOR_RUNS
838
1048
 
1049
+ # --- a run may not start before the last one finished (FR-72, 2026-09-10) --
1050
+ # `--void-run` (§2.7) gave a way to CORRECT a run record written with a start time nobody
1051
+ # measured. Nothing refused one, so the same mistake happened twice more the day the correction
1052
+ # mechanism shipped — two records whose `started` preceded the previous record's `ended`, one by
1053
+ # 42 minutes and one by nearly three hours, each silently adding that overlap to every total
1054
+ # (MISS-TechieFlow-20260910-04, sorted `ignored`: it was written down and not followed).
1055
+ #
1056
+ # A stream is append-only, so the only place to stop it is before the write. The rule: for one
1057
+ # app, a live `run` record may not begin before the last live `run` record ended. Voided and
1058
+ # backfilled records are ignored, which is what makes the void-then-re-emit flow work. Records
1059
+ # with no `started` are untouched.
1060
+ #
1061
+ # The one legitimate overlap is two machines appending to the same merge=union stream, so
1062
+ # `--allow-overlap` exists and the refusal message names it. It is deliberately a flag and not a
1063
+ # fallback: the mistake this refuses is easy to make and invisible afterwards.
1064
+ _LAST_END = None
1065
+
1066
+
1067
+ def _last_live_end(app):
1068
+ global _LAST_END
1069
+ if _LAST_END is None:
1070
+ _LAST_END = {}
1071
+ voided = set()
1072
+ rows = []
1073
+ try:
1074
+ with open(os.path.join(met_dir, "runs.jsonl"), encoding="utf-8") as fh:
1075
+ for line in fh:
1076
+ line = line.strip()
1077
+ if not line:
1078
+ continue
1079
+ try:
1080
+ r = json.loads(line)
1081
+ except Exception:
1082
+ continue
1083
+ if r.get("kind") == "run-void":
1084
+ voided.add((r.get("app"), r.get("cmd"), r.get("started")))
1085
+ elif r.get("kind", "run") == "run" and not r.get("backfilled"):
1086
+ rows.append(r)
1087
+ except Exception:
1088
+ pass
1089
+ for r in rows:
1090
+ if (r.get("app"), r.get("cmd"), r.get("started")) in voided:
1091
+ continue
1092
+ # `ended` ONLY. `ts` is when the record was WRITTEN, not when the run finished, so
1093
+ # falling back to it would make every historical record emitted today look like an
1094
+ # overlap with the record written a second ago. A run with no `ended` has no window
1095
+ # and constrains nothing.
1096
+ #
1097
+ # And only a record whose window is COHERENT: a self-contradicting one (ended before
1098
+ # started) has its `ended` replaced with the write time by the check above, and
1099
+ # marked by a null `duration_s`. That placeholder is not a measurement, so it must
1100
+ # not become a boundary — it would block every later record for the same app.
1101
+ if not isinstance(r.get("duration_s"), (int, float)):
1102
+ continue
1103
+ end = r.get("ended")
1104
+ if not end:
1105
+ continue
1106
+ a = r.get("app")
1107
+ if a not in _LAST_END or end > _LAST_END[a][0]:
1108
+ _LAST_END[a] = (end, r.get("cmd"), r.get("started"))
1109
+ return _LAST_END.get(app)
1110
+
1111
+
1112
+ def _overlap_ok(rec):
1113
+ """False (and the reason printed) when this run would start before the last one ended."""
1114
+ if stream != "runs" or rec.get("kind", "run") != "run" or rec.get("backfilled"):
1115
+ return True
1116
+ if os.environ.get("TF_ALLOW_OVERLAP") == "1":
1117
+ return True
1118
+ started = rec.get("started")
1119
+ if not started:
1120
+ return True
1121
+ prev = _last_live_end(rec.get("app"))
1122
+ if not prev:
1123
+ return True
1124
+ a, b = _iso_ms(started), _iso_ms(prev[0])
1125
+ if a is not None and b is not None:
1126
+ if a >= b:
1127
+ return True
1128
+ elif started >= prev[0]: # unparseable timestamp: fall back to the string order
1129
+ return True
1130
+ sys.stdout.write(
1131
+ "tf-emit: REFUSED — this run starts at %s, before the previous run for %s ended at %s "
1132
+ "(%s started %s). The two would overlap and every total over them would be that much too "
1133
+ "big. Use the previous run's `ended` as this one's `started`, or pass --allow-overlap if "
1134
+ "they really did run at the same time (a second machine on the same stream). "
1135
+ "Nothing was appended.\n"
1136
+ % (started, rec.get("app"), prev[0], prev[1], prev[2]))
1137
+ return False
1138
+
1139
+
839
1140
  def stamp_attempt(rec):
840
1141
  if stream != "runs" or "attempt" in rec or rec.get("backfilled"):
841
1142
  return rec
@@ -901,8 +1202,21 @@ def enrich_run(rec):
901
1202
  rec["tokens_out_subagents"] = win.get("sub_out", 0)
902
1203
  if rec.get("tier_model") and rec.get("model"):
903
1204
  rec["routed"] = rec["model"] == rec["tier_model"]
1205
+ # What a cost figure from this window MEANS. Several models in one window may
1206
+ # be paid for differently — a fallback from a subscription to a metered key is
1207
+ # exactly that case — so a mixed window says so rather than picking a winner.
1208
+ modes = {_billing(m, HARNESS)[0] for m in models} or {_billing(rec.get("model"), HARNESS)[0]}
1209
+ rec["billing_mode"] = modes.pop() if len(modes) == 1 else "mixed"
1210
+ rec["cost_source"] = "opencode-db" if HARNESS == "opencode" else "none"
1211
+ # `routed:false` because the tier model was out of quota is a different fact from
1212
+ # `routed:false` because something drifted. Only the first names a model here.
1213
+ tm = rec.get("tier_model")
1214
+ if tm and rec.get("model") and rec["model"] != tm and _was_limited(tm, HARNESS, t0 / 1000.0):
1215
+ rec["fallback_from"] = tm
904
1216
  else:
905
1217
  rec["tokens_scope"] = "none"
1218
+ rec["billing_mode"] = _billing(rec.get("tier_model"), HARNESS)[0]
1219
+ rec["cost_source"] = "none"
906
1220
  except Exception as e:
907
1221
  warn("run enrichment failed (%s) — record kept unenriched" % e)
908
1222
  return rec
@@ -970,7 +1284,7 @@ def _runs_by_start():
970
1284
  return _RUNS_BY_START
971
1285
 
972
1286
  _COST_FIELDS = ("tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write",
973
- "cost_usd", "tokens_scope", "model")
1287
+ "cost_usd", "tokens_scope", "model", "billing_mode", "cost_source")
974
1288
 
975
1289
  # Kept identical to the --amend branch above. Two doors, ONE enforcement: an
976
1290
  # agent that hand-writes a miss-amend record onto the stream faces the same
@@ -982,13 +1296,21 @@ _AMENDABLE = {
982
1296
  "dependency-not-declared", "instruction-ignored", "other"),
983
1297
  "sort": ("spec", "unsaid", "weak-check", "ignored"),
984
1298
  }
1299
+ _AMENDABLE_TEXT = ("what",) # free text, guarded by the never-overwrite rule (see --amend)
985
1300
 
986
1301
  def _amend_ok(rec):
987
1302
  """True when this miss-amend may be appended. Returning False DROPS it —
988
1303
  an invalid amend writes nothing rather than landing as a record the reader
989
1304
  then has to defend itself against."""
990
1305
  mid, fld, val = rec.get("miss_id"), rec.get("field"), rec.get("value")
991
- if fld not in _AMENDABLE or val not in _AMENDABLE[fld] or not mid:
1306
+ if not mid:
1307
+ warn("amend refused — no miss_id")
1308
+ return False
1309
+ if fld in _AMENDABLE_TEXT:
1310
+ if not isinstance(val, str) or not val.strip():
1311
+ warn("amend refused — %s must be a non-empty sentence" % fld)
1312
+ return False
1313
+ elif fld not in _AMENDABLE or val not in _AMENDABLE[fld]:
992
1314
  warn("amend refused — %r/%r outside the §5.5.7 allowlist" % (fld, val))
993
1315
  return False
994
1316
  parent, current = None, None
@@ -1084,12 +1406,19 @@ _ALLOWED = {
1084
1406
  "subagents", "files_written", "build_result", "tier", "tier_model", "model", "models",
1085
1407
  "routed", "tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write",
1086
1408
  "cost_usd", "tokens_scope", "attempt", "subagent_runs", "tokens_out_subagents",
1087
- "model_tokens_out", "session_id", "usage_start_event_ts", "usage_end_event_ts"},
1409
+ "model_tokens_out", "session_id", "usage_start_event_ts", "usage_end_event_ts",
1410
+ "billing_mode", "cost_source", "fallback_from"},
1411
+ # A correction, not a deletion: it names a run record that is wrong by the pair every
1412
+ # run carries (`cmd` + `started`) and says why, so every figure can skip it while the
1413
+ # record itself stays where it is. Written by --void-run, which refuses one that names
1414
+ # no run. SCHEMA.md §2.7; tests/regression/run.sh tf_void.
1415
+ ("runs", "run-void"): {"cmd", "started", "reason"},
1088
1416
  ("gates", "gate"): {"run_id", "req_id", "req_class", "attempt", "verdict", "gate", "gates_run",
1089
1417
  "failure_class", "prior_verdict", "proof", "escaped", "phase", "result", "detail", "at",
1090
1418
  "tier", "tier_model", "model", "models", "tokens_in", "tokens_out", "tokens_cache_read",
1091
1419
  "tokens_cache_write", "cost_usd", "tokens_scope", "model_tokens_out", "routed",
1092
- "subagent_runs", "tokens_out_subagents", "session_id"},
1420
+ "subagent_runs", "tokens_out_subagents", "session_id",
1421
+ "billing_mode", "cost_source", "fallback_from"},
1093
1422
  ("sessions", "session"): {"session_id", "model", "duration_s", "input_tokens", "output_tokens",
1094
1423
  "cache_read_tokens", "cache_creation_tokens", "cost_usd", "children_sessions"},
1095
1424
  ("commits", "commit"): {"sha", "files", "insertions", "deletions", "subject_prefix", "branch"},
@@ -1099,7 +1428,8 @@ _ALLOWED = {
1099
1428
  "what", "sort"},
1100
1429
  ("misses", "miss-fix"): {"miss_id", "req_id", "fix_run_id", "fix_cmd", "fix_attempt", "verdict_after",
1101
1430
  "reopened", "cost_attribution", "tokens_in", "tokens_out", "tokens_cache_read",
1102
- "tokens_cache_write", "cost_usd", "tokens_scope", "model"},
1431
+ "tokens_cache_write", "cost_usd", "tokens_scope", "model",
1432
+ "billing_mode", "cost_source"},
1103
1433
  ("misses", "miss-amend"): {"miss_id", "field", "value"},
1104
1434
  # an owner review of a phase's output (FR-36, built 2026-09-06): how many corrections were
1105
1435
  # given, what producing the reviewed output cost, what applying the corrections cost. The two
@@ -1111,7 +1441,8 @@ _ALLOWED = {
1111
1441
  # Fields the emitter derives for runs/gates: a caller's value is discarded.
1112
1442
  _DERIVED = ("harness", "tier", "tier_model", "model", "models", "model_tokens_out", "routed",
1113
1443
  "tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write", "cost_usd",
1114
- "tokens_scope", "subagent_runs", "tokens_out_subagents")
1444
+ "tokens_scope", "subagent_runs", "tokens_out_subagents",
1445
+ "billing_mode", "cost_source", "fallback_from")
1115
1446
 
1116
1447
 
1117
1448
  def _shape_ok(rec):
@@ -1231,7 +1562,12 @@ lines = []
1231
1562
  for rec in records:
1232
1563
  if not _shape_ok(rec):
1233
1564
  continue
1234
- out = enrich_miss(enrich_run(stamp_attempt(enrich(rec))))
1565
+ # enrich() first, because it is what fills `app` when the caller left it out — the overlap
1566
+ # rule is per app and would otherwise compare a named run against an unnamed one.
1567
+ base = enrich(rec)
1568
+ if not _overlap_ok(base):
1569
+ continue
1570
+ out = enrich_miss(enrich_run(stamp_attempt(base)))
1235
1571
  if out is None: # a refused miss-amend (§5.5.7) — dropped, never appended
1236
1572
  continue
1237
1573
  line = json.dumps(out, separators=(",", ":"), ensure_ascii=False)
@@ -1256,8 +1592,9 @@ PY
1256
1592
  python3 -c "$TF_PROG" "$MET_DIR" "$STREAM" "$ROOT" 2>"$TF_ERR"
1257
1593
 
1258
1594
  # The readable miss list beside the record (FR-31, Session 5 2026-09-07): after any write to the
1259
- # misses stream, docs/<App>-Misses.md and its HTML are rebuilt from the stream, so the file is
1260
- # never older than the record. Best effort, like everything else here.
1595
+ # misses stream, docs/<App>-Misses.md is rebuilt from the stream, so the file is never older than
1596
+ # the record. Markdown only — the miss log is an agent document and is never rendered to HTML
1597
+ # (owner, 2026-09-08). Best effort, like everything else here.
1261
1598
  if [[ "$STREAM" == "misses" ]]; then
1262
1599
  python3 "$(dirname "${BASH_SOURCE[0]}")/tf-misses-md.py" --root "$ROOT" --quiet 2>"$TF_ERR" || true
1263
1600
  fi
@@ -3,6 +3,15 @@
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
@@ -17,7 +26,7 @@ set -u
17
26
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
27
  [[ $# -ge 1 && "$1" != "-h" && "$1" != "--help" ]] || { sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 3; }
19
28
  APP="$1"; shift
20
- STARTED=""; REQS=""; SUBS=""; BUILD="pass"; FILES=""
29
+ STARTED=""; REQS=""; SUBS=""; BUILD="pass"; FILES=""; MISSES=""; FIXCMD="fix-issues"; VERDICT=""
21
30
  while [[ $# -gt 0 ]]; do
22
31
  case "$1" in
23
32
  --started) STARTED="${2:-}"; shift 2 ;;
@@ -25,11 +34,15 @@ while [[ $# -gt 0 ]]; do
25
34
  --subagents) SUBS="${2:-}"; shift 2 ;;
26
35
  --build) BUILD="${2:-}"; shift 2 ;;
27
36
  --files) FILES="${2:-}"; shift 2 ;;
37
+ --misses) MISSES="${2:-}"; shift 2 ;;
38
+ --fix-cmd) FIXCMD="${2:-}"; shift 2 ;;
39
+ --verdict) VERDICT="${2:-}"; shift 2 ;;
28
40
  *) echo "tf-fix-close: unknown argument $1" >&2; exit 3 ;;
29
41
  esac
30
42
  done
31
43
  [[ -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'
44
+ TF_APP="$APP" TF_STARTED="$STARTED" TF_REQS="$REQS" TF_SUBS="$SUBS" TF_BUILD="$BUILD" TF_FILES="$FILES" \
45
+ TF_MISSES="$MISSES" TF_FIXCMD="$FIXCMD" TF_VERDICT="$VERDICT" TF_EMIT="$HERE/tf-emit.sh" python3 - <<'PY'
33
46
  import datetime, json, os, subprocess
34
47
  EMIT = os.environ["TF_EMIT"]; app = os.environ["TF_APP"]; started = os.environ["TF_STARTED"]
35
48
  now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -39,6 +52,25 @@ def emit(stream, rec):
39
52
  msg = (p.stdout + p.stderr).strip(); ok = p.returncode == 0 and "refus" not in msg.lower() and "error" not in msg.lower()
40
53
  if not ok: print(f" {stream}: not written — {msg.splitlines()[0][:160] if msg else 'no reason printed'}")
41
54
  return ok
55
+ # --- close by miss id (the *amend-docs door) -------------------------------------------
56
+ direct = [m.strip() for m in (os.environ.get("TF_MISSES") or "").split(",") if m.strip()]
57
+ if direct:
58
+ fix_cmd = os.environ.get("TF_FIXCMD") or "amend-docs"
59
+ verdict = os.environ.get("TF_VERDICT") or "Verified"
60
+ closed = 0
61
+ for mid in direct:
62
+ fa = q("--next-fix-attempt", mid) or "1"
63
+ rec = {"kind": "miss-fix", "miss_id": mid, "fix_cmd": fix_cmd,
64
+ "fix_attempt": int(fa) if fa.isdigit() else 1,
65
+ "verdict_after": verdict, "reopened": False}
66
+ if started:
67
+ rec["fix_run_id"] = started
68
+ if emit("misses", rec):
69
+ closed += 1
70
+ print("%s: %d of %d miss-fix record(s) written (no run record — the amending command emits its own)"
71
+ % (fix_cmd, closed, len(direct)))
72
+ raise SystemExit(0)
73
+
42
74
  ledger = {}
43
75
  try:
44
76
  ledger = json.load(open(os.path.join("docs", ".last-verify.json"), encoding="utf-8"))