@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
@@ -19,6 +19,12 @@
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.
26
+ # tf-emit.sh --void-miss MISS-App-20260919-01 "logged again from an earlier triage's leftovers"
27
+ # # the same for a WRONG miss (SCHEMA.md §5.5.11)
22
28
  # tf-emit.sh --origin-of REQ-UI-014 # prints "<started> <cmd> <agent>" of the last build or fix
23
29
  # # run that touched the row, nothing when there is none
24
30
  # tf-emit.sh --where # prints the resolved docs/metrics dir
@@ -270,13 +276,72 @@ fi
270
276
  # patience rather than quality. Open = no miss-fix, or the latest miss-fix
271
277
  # closed with a verdict_after other than "Verified". Prints "<miss_id>
272
278
  # <miss_class>" for the most recent open miss, or nothing at all.
279
+ # --- list the open misses whose deficient artifact is a document ----------
280
+ # `*amend-docs` is the command that edits a BRD, an Architecture or a mockup, so it is
281
+ # the only command that can close a miss whose artifact is one of those -- and it had no
282
+ # way to find them. Every other close path works off a checklist row the verifier touched,
283
+ # which a BRD edit never is, so a document miss stayed open forever (TF-016: twelve
284
+ # finished TfLens items showed as outstanding for up to two weeks).
285
+ #
286
+ # tf-emit.sh --open-misses <App> [--artifact-class doc]
287
+ #
288
+ # Prints one line per open miss: "<miss_id> <artifact> <what>". Open means no miss-fix
289
+ # carries verdict_after Verified, the same test --open-miss applies.
290
+ if [[ "$1" == "--open-misses" ]]; then
291
+ OMAPP="${2:-}"; OMCLASS=""
292
+ [[ "${3:-}" == "--artifact-class" ]] && OMCLASS="${4:-}"
293
+ TF_OMAPP="$OMAPP" TF_OMCLASS="$OMCLASS" python3 - "$MET_DIR/misses.jsonl" 2>"$TF_ERR" <<'PY2' || true
294
+ import json, os, sys
295
+ DOC = {"brd", "architecture", "uidesign", "checklist", "devguide"}
296
+ app, klass = os.environ.get("TF_OMAPP") or "", os.environ.get("TF_OMCLASS") or ""
297
+ opened, fixes, voided = [], {}, set()
298
+ try:
299
+ with open(sys.argv[1], encoding="utf-8") as fh:
300
+ for line in fh:
301
+ line = line.strip()
302
+ if not line:
303
+ continue
304
+ try:
305
+ r = json.loads(line)
306
+ except Exception:
307
+ continue
308
+ if r.get("kind") == "miss":
309
+ opened.append(r)
310
+ elif r.get("kind") == "miss-void":
311
+ voided.add(r.get("miss_id"))
312
+ elif r.get("kind") == "miss-fix":
313
+ mid = r.get("miss_id")
314
+ if mid:
315
+ prev = fixes.get(mid)
316
+ if prev is None or (r.get("ts") or "") >= (prev.get("ts") or ""):
317
+ fixes[mid] = r
318
+ except FileNotFoundError:
319
+ pass
320
+ for r in opened:
321
+ if r.get("miss_id") in voided:
322
+ continue
323
+ if app and r.get("app") and r.get("app") != app:
324
+ continue
325
+ art = r.get("artifact") or ""
326
+ if klass == "doc" and art not in DOC:
327
+ continue
328
+ if klass and klass != "doc" and art != klass:
329
+ continue
330
+ fix = fixes.get(r.get("miss_id"))
331
+ if fix is not None and fix.get("verdict_after") == "Verified":
332
+ continue
333
+ print("%s %s %s" % (r.get("miss_id"), art or "-", (r.get("what") or "")[:110]))
334
+ PY2
335
+ exit 0
336
+ fi
337
+
273
338
  if [[ "$1" == "--open-miss" ]]; then
274
339
  REQ="$2"
275
340
  [[ -n "$REQ" ]] || exit 0
276
341
  python3 - "$MET_DIR/misses.jsonl" "$REQ" 2>"$TF_ERR" <<'PY' || true
277
342
  import json, sys
278
343
  path, req = sys.argv[1], sys.argv[2]
279
- opened, fixes = [], {}
344
+ opened, fixes, voided = [], {}, set()
280
345
  try:
281
346
  with open(path, encoding="utf-8") as fh:
282
347
  for line in fh:
@@ -289,6 +354,8 @@ try:
289
354
  continue
290
355
  if r.get("kind") == "miss" and r.get("req_id") == req:
291
356
  opened.append(r)
357
+ elif r.get("kind") == "miss-void":
358
+ voided.add(r.get("miss_id"))
292
359
  elif r.get("kind") == "miss-fix":
293
360
  mid = r.get("miss_id")
294
361
  if mid:
@@ -298,6 +365,8 @@ try:
298
365
  except FileNotFoundError:
299
366
  pass
300
367
  for r in reversed(opened): # most recent first
368
+ if r.get("miss_id") in voided:
369
+ continue
301
370
  fix = fixes.get(r.get("miss_id"))
302
371
  if fix is None or fix.get("verdict_after") != "Verified":
303
372
  print("%s %s" % (r.get("miss_id"), r.get("miss_class") or "other"))
@@ -344,9 +413,19 @@ AMENDABLE = {
344
413
  # still make later, so a miss logged before the field existed can be sorted now.
345
414
  "sort": ("spec", "unsaid", "weak-check", "ignored"),
346
415
  }
347
- if fld not in AMENDABLE:
416
+ # `what` is the miss's own sentence, and it is FREE TEXT -- the one field here that
417
+ # is not a closed vocabulary. It is allowed because the protection was never the
418
+ # vocabulary: an amend may only fill a field that is still null and can never
419
+ # overwrite one that is set (below), so free text cannot rewrite a fact, only
420
+ # supply one that was missing. Validated as non-empty text instead.
421
+ # MISS-TechieFlow-20260909-02; tests/regression/run.sh tf_018.
422
+ AMENDABLE_TEXT = ("what",)
423
+ if fld not in AMENDABLE and fld not in AMENDABLE_TEXT:
348
424
  print("REFUSED %s is not an amendable field (SCHEMA.md §5.5.7)" % fld); raise SystemExit(0)
349
- if val not in AMENDABLE[fld]:
425
+ if fld in AMENDABLE_TEXT:
426
+ if not isinstance(val, str) or not val.strip():
427
+ print("REFUSED %s must be a non-empty sentence" % fld); raise SystemExit(0)
428
+ elif val not in AMENDABLE[fld]:
350
429
  print("REFUSED %r is not in the closed vocabulary for %s" % (val, fld)); raise SystemExit(0)
351
430
 
352
431
  parent, current = None, None
@@ -389,8 +468,116 @@ PY
389
468
  exit 0
390
469
  fi
391
470
 
471
+ # --- write helper: withdraw a miss that should never have been written -----
472
+ # SCHEMA.md §5.5.11 — --void-run's answer for the misses stream (TrBlazeUI TF-001).
473
+ if [[ "$1" == "--void-miss" ]]; then
474
+ VMID="$2"; VWHY="$3"
475
+ VREC="$(python3 - "$MET_DIR/misses.jsonl" "$VMID" "$VWHY" 2>"$TF_ERR" <<'PY' || true
476
+ import json, sys
477
+ path, mid, why = sys.argv[1], sys.argv[2], sys.argv[3]
478
+ found, voided = False, False
479
+ try:
480
+ for line in open(path, encoding="utf-8"):
481
+ try:
482
+ r = json.loads(line)
483
+ except Exception:
484
+ continue
485
+ if r.get("miss_id") == mid:
486
+ found = found or r.get("kind") == "miss"
487
+ voided = voided or r.get("kind") == "miss-void"
488
+ except FileNotFoundError:
489
+ pass
490
+ if not found:
491
+ print("REFUSED no miss record %s on this stream" % mid)
492
+ elif voided:
493
+ print("REFUSED %s is already voided — one void is enough" % mid)
494
+ elif not why.strip():
495
+ print("REFUSED a void needs a reason a reader can check")
496
+ else:
497
+ print("OK " + json.dumps({"kind": "miss-void", "miss_id": mid, "reason": why.strip()},
498
+ separators=(",", ":"), ensure_ascii=False))
499
+ PY
500
+ )"
501
+ case "$VREC" in
502
+ "OK "*) printf '%s' "${VREC#OK }" | bash "${BASH_SOURCE[0]}" misses
503
+ echo "tf-emit: voided $VMID — $VWHY" ;;
504
+ "REFUSED "*) echo "tf-emit: void refused — ${VREC#REFUSED }" ;;
505
+ *) echo "tf-emit: void could not be evaluated — nothing written" ;;
506
+ esac
507
+ exit 0
508
+ fi
509
+
510
+ # --- write helper: mark a run record as not to be counted -----------------
511
+ # SCHEMA.md §2.7. The same doctrine as --amend, one stream over: a run record that
512
+ # is WRONG cannot be edited or deleted, because the stream is append-only and the
513
+ # evidence that it happened is worth keeping. So the correction is another record.
514
+ #
515
+ # tf-emit.sh --void-run <cmd> <started> "<why it is wrong>"
516
+ #
517
+ # It names the run by its own `cmd` and `started` — the pair every run record
518
+ # carries and the key the readers already use — and every figure then skips it,
519
+ # with the count and the reasons printed. A void that names no run on this stream
520
+ # is refused: it would be a claim about nothing. It is deliberately NOT a delete:
521
+ # both records stay, and a reader can see what was corrected and why.
522
+ if [[ "$1" == "--void-run" ]]; then
523
+ VCMD="$2"; VSTART="$3"; VWHY="$4"
524
+ if [[ -z "$VCMD" || -z "$VSTART" || -z "$VWHY" ]]; then
525
+ echo "tf-emit: --void-run needs <cmd> <started> \"<reason>\""
526
+ exit 0
527
+ fi
528
+ VREC="$(python3 - "$MET_DIR/runs.jsonl" "$VCMD" "$VSTART" "$VWHY" 2>"$TF_ERR" <<'PY' || true
529
+ import json, sys
530
+ path, cmd, started, why = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
531
+ target, voided, app = None, False, None
532
+ try:
533
+ with open(path, encoding="utf-8") as fh:
534
+ for line in fh:
535
+ line = line.strip()
536
+ if not line:
537
+ continue
538
+ try:
539
+ r = json.loads(line)
540
+ except Exception:
541
+ continue
542
+ if r.get("cmd") == cmd and r.get("started") == started:
543
+ if r.get("kind") == "run-void":
544
+ voided = True
545
+ elif r.get("kind", "run") == "run":
546
+ target = r
547
+ app = r.get("app")
548
+ except FileNotFoundError:
549
+ pass
550
+
551
+ if target is None:
552
+ print("REFUSED no run record %s started %s on this stream" % (cmd, started)); raise SystemExit(0)
553
+ if voided:
554
+ print("REFUSED %s started %s is already voided — one void is enough" % (cmd, started)); raise SystemExit(0)
555
+ if not why.strip():
556
+ print("REFUSED a void needs a reason a reader can check"); raise SystemExit(0)
557
+
558
+ print("OK " + json.dumps({"kind": "run-void", "app": app, "cmd": cmd,
559
+ "started": started, "reason": why.strip()},
560
+ separators=(",", ":"), ensure_ascii=False))
561
+ PY
562
+ )"
563
+ case "$VREC" in
564
+ "OK "*) printf '%s' "${VREC#OK }" | bash "${BASH_SOURCE[0]}" runs
565
+ echo "tf-emit: voided $VCMD started $VSTART — $VWHY"
566
+ echo " both records stay on the stream; every figure now skips the run and says so" ;;
567
+ "REFUSED "*) echo "tf-emit: void refused — ${VREC#REFUSED }" ;;
568
+ *) echo "tf-emit: void could not be evaluated — nothing written" ;;
569
+ esac
570
+ exit 0
571
+ fi
572
+
392
573
  # --- append path ----------------------------------------------------------
393
574
  STREAM="$1"
575
+ # --allow-overlap: this run really did happen while another was still open (a second machine
576
+ # appending to the same merge=union stream is the only case that has ever come up). Without it a
577
+ # run whose `started` precedes the previous run's `ended` is refused — see _overlap_ok below.
578
+ TF_ALLOW_OVERLAP=0
579
+ for _a in "$@"; do [[ "$_a" == "--allow-overlap" ]] && TF_ALLOW_OVERLAP=1; done
580
+ export TF_ALLOW_OVERLAP
394
581
  case "$STREAM" in
395
582
  runs|gates|sessions|commits|misses) ;;
396
583
  *) _warn "unknown stream '${STREAM:-<none>}' — event dropped"; exit 0 ;;
@@ -398,8 +585,8 @@ esac
398
585
 
399
586
  # The program is captured into a variable rather than fed on stdin — a heredoc
400
587
  # on `python3 -` would REPLACE the caller's piped JSON and silently drop every event.
401
- TF_PROG="$(cat <<'PY'
402
- import json, os, sys, datetime, glob, re, time
588
+ IFS= read -r -d '' TF_PROG <<'PY' || true # bash 3.2 misreads a here-document inside a command substitution
589
+ import json, os, sys, datetime, glob, re, subprocess, time
403
590
 
404
591
  met_dir, stream, root = sys.argv[1], sys.argv[2], sys.argv[3]
405
592
  dbg = os.environ.get("TF_METRICS_DEBUG")
@@ -597,15 +784,41 @@ def enrich(rec):
597
784
  if rec.get("kind") == "run" and isinstance(rec.get("ended"), str):
598
785
  t_end, t_now = _iso_ms(rec["ended"]), _iso_ms(NOW)
599
786
  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))
787
+ # Two different faults, and they must not share a remedy.
788
+ #
789
+ # (a) `ended` a little in the future: `started` is still trustworthy, so clamp to
790
+ # now and re-derive (MISS-TechieFlow-20260905-11, a day-1 run wrote 17:05 at 16:40).
791
+ # (b) `ended` BEFORE `started`: the record contradicts itself and there is no way to
792
+ # tell which of the two is wrong, so it carries NO duration at all. Deriving one
793
+ # from the surviving timestamp invents a figure -- a TechieBlog record started
794
+ # 2026-08-22 would have been "measured" at 1,515,038 seconds on the day it was
795
+ # re-emitted. An honest gap beats a plausible number: SCHEMA §2.5.
796
+ if t_end is not None and t_now is not None and t_start is not None and t_end < t_start:
797
+ sys.stdout.write("tf-emit: ended %s precedes started %s; the record contradicts "
798
+ "itself, so no duration is stored\n" % (rec["ended"], rec["started"]))
799
+ rec["ended"] = NOW
800
+ rec.pop("duration_s", None)
801
+ rec["duration_unmeasured"] = "ended-before-started"
802
+ elif t_end is not None and t_now is not None and t_end > t_now + 60_000:
803
+ sys.stdout.write("tf-emit: ended %s is not a measurement (it lies in the future); "
804
+ "set to now, %s\n" % (rec["ended"], NOW))
603
805
  rec["ended"] = NOW
604
806
  if t_start is not None and "duration_s" in rec:
605
807
  rec["duration_s"] = max(0, (t_now - t_start) // 1000)
808
+ elif t_end is not None and t_start is not None and isinstance(rec.get("duration_s"), (int, float)):
809
+ # A supplied duration that bears no relation to its own timestamps. Thirteen of
810
+ # TechieBlog's fourteen impossible records hid here, storing a plausible round
811
+ # number (3600, 2700, 1800, 900, 600) that nothing downstream could question --
812
+ # only the ONE record that stored the negative arithmetic was ever detectable.
813
+ span = (t_end - t_start) // 1000
814
+ if abs(span - rec["duration_s"]) > 1:
815
+ sys.stdout.write("tf-emit: duration_s %s disagrees with started/ended (%ss); "
816
+ "using the timestamps\n" % (rec["duration_s"], span))
817
+ rec["duration_s"] = span
606
818
  # A run record that omits duration_s gets it from started and ended (the devguide and
607
819
  # 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"):
820
+ if rec.get("kind") == "run" and "duration_s" not in rec and rec.get("started") and rec.get("ended") \
821
+ and not rec.get("duration_unmeasured"):
609
822
  try:
610
823
  rec["duration_s"] = max(0, (_iso_ms(rec["ended"]) - _iso_ms(rec["started"])) // 1000)
611
824
  except Exception:
@@ -663,6 +876,52 @@ def _routing():
663
876
 
664
877
  ROUTING = _routing()
665
878
 
879
+ # --- how a model is paid for (SCHEMA.md §2.5b, added 2026-09-10) -----------
880
+ # One cost column cannot mean three things at once. A run on a flat-fee subscription records
881
+ # zero marginal dollars, a run on a metered API key records real money, and a run on a local
882
+ # model records zero because there is no bill — and until this field existed a reader could
883
+ # not tell those three zeroes apart, so every "what did it cost" figure quietly averaged real
884
+ # spending with free runs. The answer is resolved by tf-model-pick.sh, which reads OpenCode's
885
+ # own auth.json and model catalog, so it is looked up and never declared by an agent — the
886
+ # same rule as `harness` (§1) and `origin_model` (§5.5.1).
887
+ _BILLING_CACHE = {}
888
+
889
+
890
+ def _billing(model, harness):
891
+ key = (model, harness)
892
+ if key not in _BILLING_CACHE:
893
+ mode, source = "unknown", "none"
894
+ pick = os.path.join(root, ".tfcore", "utils", "tf-model-pick.sh")
895
+ if model and os.path.isfile(pick):
896
+ try:
897
+ out = subprocess.run(["bash", pick, "billing", model, harness or "claude"],
898
+ capture_output=True, text=True, timeout=20,
899
+ env=dict(os.environ, TF_PROJECT_DIR=root))
900
+ parts = (out.stdout or "").strip().split("\t")
901
+ if parts and parts[0] in ("subscription", "plan", "metered", "local", "unknown"):
902
+ mode = parts[0]
903
+ source = parts[1] if len(parts) > 1 else "tf-model-pick"
904
+ except Exception:
905
+ pass
906
+ _BILLING_CACHE[key] = (mode, source)
907
+ return _BILLING_CACHE[key]
908
+
909
+
910
+ def _was_limited(model, harness, at_epoch):
911
+ """Did this run start while the tier's own model was parked? Then it is a deliberate
912
+ fallback, not routing drift, and `routed:false` should not be read as one."""
913
+ pick = os.path.join(root, ".tfcore", "utils", "tf-model-pick.sh")
914
+ if not model or not os.path.isfile(pick) or at_epoch is None:
915
+ return False
916
+ try:
917
+ out = subprocess.run(["bash", pick, "was-limited", harness or "claude", model, str(int(at_epoch))],
918
+ capture_output=True, text=True, timeout=20,
919
+ env=dict(os.environ, TF_PROJECT_DIR=root))
920
+ return out.returncode == 0
921
+ except Exception:
922
+ return False
923
+
924
+
666
925
  def _iso_ms(s):
667
926
  try:
668
927
  return int(datetime.datetime.strptime(
@@ -836,6 +1095,104 @@ def _prior_runs():
836
1095
  pass
837
1096
  return _PRIOR_RUNS
838
1097
 
1098
+ # --- a run may not start before the last one finished (FR-72, 2026-09-10) --
1099
+ # `--void-run` (§2.7) gave a way to CORRECT a run record written with a start time nobody
1100
+ # measured. Nothing refused one, so the same mistake happened twice more the day the correction
1101
+ # mechanism shipped — two records whose `started` preceded the previous record's `ended`, one by
1102
+ # 42 minutes and one by nearly three hours, each silently adding that overlap to every total
1103
+ # (MISS-TechieFlow-20260910-04, sorted `ignored`: it was written down and not followed).
1104
+ #
1105
+ # A stream is append-only, so the only place to stop it is before the write. The rule: for one
1106
+ # app, a live `run` record's window may not OVERLAP another live `run` record's window. It is the
1107
+ # windows that are compared, not the order of writing: a command that chains another one inside
1108
+ # itself -- *build-phase chaining *verify -- finishes last but did its own first segment before the
1109
+ # inner run started, and comparing against the newest record alone meant that segment could never be
1110
+ # recorded at all (TfLens TF-040, 80 minutes and five builder clusters on no record). Two windows
1111
+ # that merely touch at a boundary do not overlap. Voided and backfilled records are ignored, which
1112
+ # is what makes the void-then-re-emit flow work. Records with no `started` are untouched.
1113
+ #
1114
+ # The one legitimate overlap is two machines appending to the same merge=union stream, so
1115
+ # `--allow-overlap` exists and the refusal message names it. It is deliberately a flag and not a
1116
+ # fallback: the mistake this refuses is easy to make and invisible afterwards.
1117
+ _LIVE = None
1118
+
1119
+
1120
+ def _live_windows(app):
1121
+ """[(started, ended, cmd)] of every live run record for this app, oldest first."""
1122
+ global _LIVE
1123
+ if _LIVE is None:
1124
+ _LIVE = {}
1125
+ voided = set()
1126
+ rows = []
1127
+ try:
1128
+ with open(os.path.join(met_dir, "runs.jsonl"), encoding="utf-8") as fh:
1129
+ for line in fh:
1130
+ line = line.strip()
1131
+ if not line:
1132
+ continue
1133
+ try:
1134
+ r = json.loads(line)
1135
+ except Exception:
1136
+ continue
1137
+ if r.get("kind") == "run-void":
1138
+ voided.add((r.get("app"), r.get("cmd"), r.get("started")))
1139
+ elif r.get("kind", "run") == "run" and not r.get("backfilled"):
1140
+ rows.append(r)
1141
+ except Exception:
1142
+ pass
1143
+ for r in rows:
1144
+ if (r.get("app"), r.get("cmd"), r.get("started")) in voided:
1145
+ continue
1146
+ start = r.get("started")
1147
+ if not start:
1148
+ continue
1149
+ # `ended` ONLY. `ts` is when the record was WRITTEN, not when the run finished, so
1150
+ # falling back to it would make every historical record emitted today look like an
1151
+ # overlap with the record written a second ago. A run with no `ended` has no window
1152
+ # and constrains nothing.
1153
+ #
1154
+ # And only a record whose window is COHERENT: a self-contradicting one (ended before
1155
+ # started) has its `ended` replaced with the write time by the check above, and
1156
+ # marked by a null `duration_s`. That placeholder is not a measurement, so it must
1157
+ # not become a boundary — it would block every later record for the same app.
1158
+ if not isinstance(r.get("duration_s"), (int, float)):
1159
+ continue
1160
+ end = r.get("ended")
1161
+ if not end:
1162
+ continue
1163
+ _LIVE.setdefault(r.get("app"), []).append((start, end, r.get("cmd")))
1164
+ return _LIVE.get(app, [])
1165
+
1166
+
1167
+ def _overlap_ok(rec):
1168
+ """False (and the reason printed) when this run's window overlaps a live one's."""
1169
+ if stream != "runs" or rec.get("kind", "run") != "run" or rec.get("backfilled"):
1170
+ return True
1171
+ if os.environ.get("TF_ALLOW_OVERLAP") == "1":
1172
+ return True
1173
+ started, ended = rec.get("started"), rec.get("ended")
1174
+ if not started:
1175
+ return True
1176
+ def key(v):
1177
+ k = _iso_ms(v)
1178
+ return k if k is not None else v # unparseable: compare as strings, as before
1179
+ s, e = key(started), key(ended or started)
1180
+ for (ps, pe, pcmd) in _live_windows(rec.get("app")):
1181
+ a, b = key(ps), key(pe)
1182
+ if type(a) is not type(s) or type(b) is not type(e):
1183
+ continue
1184
+ if s < b and a < e: # windows that only touch at a boundary are fine
1185
+ sys.stdout.write(
1186
+ "tf-emit: REFUSED — this run (%s to %s) overlaps the %s run of %s already on the "
1187
+ "stream (%s to %s). Every total over the two would be that much too big. Record the "
1188
+ "part that did not overlap -- a command that chained another one records its own "
1189
+ "segment before the inner run started -- or pass --allow-overlap if they really did "
1190
+ "run at the same time (a second machine on the same stream). Nothing was appended.\n"
1191
+ % (started, ended or "now", pcmd, rec.get("app"), ps, pe))
1192
+ return False
1193
+ return True
1194
+
1195
+
839
1196
  def stamp_attempt(rec):
840
1197
  if stream != "runs" or "attempt" in rec or rec.get("backfilled"):
841
1198
  return rec
@@ -901,8 +1258,21 @@ def enrich_run(rec):
901
1258
  rec["tokens_out_subagents"] = win.get("sub_out", 0)
902
1259
  if rec.get("tier_model") and rec.get("model"):
903
1260
  rec["routed"] = rec["model"] == rec["tier_model"]
1261
+ # What a cost figure from this window MEANS. Several models in one window may
1262
+ # be paid for differently — a fallback from a subscription to a metered key is
1263
+ # exactly that case — so a mixed window says so rather than picking a winner.
1264
+ modes = {_billing(m, HARNESS)[0] for m in models} or {_billing(rec.get("model"), HARNESS)[0]}
1265
+ rec["billing_mode"] = modes.pop() if len(modes) == 1 else "mixed"
1266
+ rec["cost_source"] = "opencode-db" if HARNESS == "opencode" else "none"
1267
+ # `routed:false` because the tier model was out of quota is a different fact from
1268
+ # `routed:false` because something drifted. Only the first names a model here.
1269
+ tm = rec.get("tier_model")
1270
+ if tm and rec.get("model") and rec["model"] != tm and _was_limited(tm, HARNESS, t0 / 1000.0):
1271
+ rec["fallback_from"] = tm
904
1272
  else:
905
1273
  rec["tokens_scope"] = "none"
1274
+ rec["billing_mode"] = _billing(rec.get("tier_model"), HARNESS)[0]
1275
+ rec["cost_source"] = "none"
906
1276
  except Exception as e:
907
1277
  warn("run enrichment failed (%s) — record kept unenriched" % e)
908
1278
  return rec
@@ -970,7 +1340,7 @@ def _runs_by_start():
970
1340
  return _RUNS_BY_START
971
1341
 
972
1342
  _COST_FIELDS = ("tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write",
973
- "cost_usd", "tokens_scope", "model")
1343
+ "cost_usd", "tokens_scope", "model", "billing_mode", "cost_source")
974
1344
 
975
1345
  # Kept identical to the --amend branch above. Two doors, ONE enforcement: an
976
1346
  # agent that hand-writes a miss-amend record onto the stream faces the same
@@ -982,13 +1352,21 @@ _AMENDABLE = {
982
1352
  "dependency-not-declared", "instruction-ignored", "other"),
983
1353
  "sort": ("spec", "unsaid", "weak-check", "ignored"),
984
1354
  }
1355
+ _AMENDABLE_TEXT = ("what",) # free text, guarded by the never-overwrite rule (see --amend)
985
1356
 
986
1357
  def _amend_ok(rec):
987
1358
  """True when this miss-amend may be appended. Returning False DROPS it —
988
1359
  an invalid amend writes nothing rather than landing as a record the reader
989
1360
  then has to defend itself against."""
990
1361
  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:
1362
+ if not mid:
1363
+ warn("amend refused — no miss_id")
1364
+ return False
1365
+ if fld in _AMENDABLE_TEXT:
1366
+ if not isinstance(val, str) or not val.strip():
1367
+ warn("amend refused — %s must be a non-empty sentence" % fld)
1368
+ return False
1369
+ elif fld not in _AMENDABLE or val not in _AMENDABLE[fld]:
992
1370
  warn("amend refused — %r/%r outside the §5.5.7 allowlist" % (fld, val))
993
1371
  return False
994
1372
  parent, current = None, None
@@ -1084,12 +1462,19 @@ _ALLOWED = {
1084
1462
  "subagents", "files_written", "build_result", "tier", "tier_model", "model", "models",
1085
1463
  "routed", "tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write",
1086
1464
  "cost_usd", "tokens_scope", "attempt", "subagent_runs", "tokens_out_subagents",
1087
- "model_tokens_out", "session_id", "usage_start_event_ts", "usage_end_event_ts"},
1465
+ "model_tokens_out", "session_id", "usage_start_event_ts", "usage_end_event_ts",
1466
+ "billing_mode", "cost_source", "fallback_from"},
1467
+ # A correction, not a deletion: it names a run record that is wrong by the pair every
1468
+ # run carries (`cmd` + `started`) and says why, so every figure can skip it while the
1469
+ # record itself stays where it is. Written by --void-run, which refuses one that names
1470
+ # no run. SCHEMA.md §2.7; tests/regression/run.sh tf_void.
1471
+ ("runs", "run-void"): {"cmd", "started", "reason"},
1088
1472
  ("gates", "gate"): {"run_id", "req_id", "req_class", "attempt", "verdict", "gate", "gates_run",
1089
1473
  "failure_class", "prior_verdict", "proof", "escaped", "phase", "result", "detail", "at",
1090
1474
  "tier", "tier_model", "model", "models", "tokens_in", "tokens_out", "tokens_cache_read",
1091
1475
  "tokens_cache_write", "cost_usd", "tokens_scope", "model_tokens_out", "routed",
1092
- "subagent_runs", "tokens_out_subagents", "session_id"},
1476
+ "subagent_runs", "tokens_out_subagents", "session_id",
1477
+ "billing_mode", "cost_source", "fallback_from"},
1093
1478
  ("sessions", "session"): {"session_id", "model", "duration_s", "input_tokens", "output_tokens",
1094
1479
  "cache_read_tokens", "cache_creation_tokens", "cost_usd", "children_sessions"},
1095
1480
  ("commits", "commit"): {"sha", "files", "insertions", "deletions", "subject_prefix", "branch"},
@@ -1099,8 +1484,11 @@ _ALLOWED = {
1099
1484
  "what", "sort"},
1100
1485
  ("misses", "miss-fix"): {"miss_id", "req_id", "fix_run_id", "fix_cmd", "fix_attempt", "verdict_after",
1101
1486
  "reopened", "cost_attribution", "tokens_in", "tokens_out", "tokens_cache_read",
1102
- "tokens_cache_write", "cost_usd", "tokens_scope", "model"},
1487
+ "tokens_cache_write", "cost_usd", "tokens_scope", "model",
1488
+ "billing_mode", "cost_source"},
1103
1489
  ("misses", "miss-amend"): {"miss_id", "field", "value"},
1490
+ # a miss that should never have been written, withdrawn by --void-miss (SCHEMA.md §5.5.11)
1491
+ ("misses", "miss-void"): {"miss_id", "reason"},
1104
1492
  # an owner review of a phase's output (FR-36, built 2026-09-06): how many corrections were
1105
1493
  # given, what producing the reviewed output cost, what applying the corrections cost. The two
1106
1494
  # costs are copied from the runs named by reviewed_run_id and correction_run_id (their `started`).
@@ -1111,7 +1499,8 @@ _ALLOWED = {
1111
1499
  # Fields the emitter derives for runs/gates: a caller's value is discarded.
1112
1500
  _DERIVED = ("harness", "tier", "tier_model", "model", "models", "model_tokens_out", "routed",
1113
1501
  "tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write", "cost_usd",
1114
- "tokens_scope", "subagent_runs", "tokens_out_subagents")
1502
+ "tokens_scope", "subagent_runs", "tokens_out_subagents",
1503
+ "billing_mode", "cost_source", "fallback_from")
1115
1504
 
1116
1505
 
1117
1506
  def _shape_ok(rec):
@@ -1231,7 +1620,12 @@ lines = []
1231
1620
  for rec in records:
1232
1621
  if not _shape_ok(rec):
1233
1622
  continue
1234
- out = enrich_miss(enrich_run(stamp_attempt(enrich(rec))))
1623
+ # enrich() first, because it is what fills `app` when the caller left it out — the overlap
1624
+ # rule is per app and would otherwise compare a named run against an unnamed one.
1625
+ base = enrich(rec)
1626
+ if not _overlap_ok(base):
1627
+ continue
1628
+ out = enrich_miss(enrich_run(stamp_attempt(base)))
1235
1629
  if out is None: # a refused miss-amend (§5.5.7) — dropped, never appended
1236
1630
  continue
1237
1631
  line = json.dumps(out, separators=(",", ":"), ensure_ascii=False)
@@ -1252,12 +1646,12 @@ except Exception as e:
1252
1646
  warn("append failed (%s) — event dropped" % e)
1253
1647
  raise SystemExit(0)
1254
1648
  PY
1255
- )"
1256
1649
  python3 -c "$TF_PROG" "$MET_DIR" "$STREAM" "$ROOT" 2>"$TF_ERR"
1257
1650
 
1258
1651
  # 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.
1652
+ # misses stream, docs/<App>-Misses.md is rebuilt from the stream, so the file is never older than
1653
+ # the record. Markdown only — the miss log is an agent document and is never rendered to HTML
1654
+ # (owner, 2026-09-08). Best effort, like everything else here.
1261
1655
  if [[ "$STREAM" == "misses" ]]; then
1262
1656
  python3 "$(dirname "${BASH_SOURCE[0]}")/tf-misses-md.py" --root "$ROOT" --quiet 2>"$TF_ERR" || true
1263
1657
  fi
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ # tf-feedback.sh — the state of every entry in this project's upstream feedback files, and the
3
+ # one way to mark an entry closed once its fix has been re-checked here (2026-09-11).
4
+ #
5
+ # bash .tfcore/utils/tf-feedback.sh <App> every entry: open, fixed upstream, closed
6
+ # bash .tfcore/utils/tf-feedback.sh <App> --waiting fixed upstream, not yet re-checked here
7
+ # bash .tfcore/utils/tf-feedback.sh <App> --close <ID> "<what you ran and what it showed>"
8
+ #
9
+ # Details: tf_feedback.py. Writes nothing except the one closing line --close adds.
10
+ set -u
11
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+ command -v python3 >/dev/null 2>&1 || { echo "tf-feedback: python3 is required" >&2; exit 2; }
13
+ exec python3 "$HERE/tf_feedback.py" "$@"