@techierathore/techieflow 1.1.0 → 1.2.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 (106) hide show
  1. package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
  2. package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
  3. package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
  4. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +7 -5
  5. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
  6. package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
  7. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
  8. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +4 -4
  9. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  10. package/.claude/commands/TechieFlow/tasks/fix-issues.md +1 -0
  11. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  12. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  14. package/.opencode/plugin/techieflow.js +15 -7
  15. package/.tfcore/agents/flow-master.md +2 -0
  16. package/.tfcore/agents/verifier.md +2 -2
  17. package/.tfcore/core-config.yaml +2 -1
  18. package/.tfcore/hooks/block-git.sh +25 -2
  19. package/.tfcore/hooks/guard-db.sh +106 -9
  20. package/.tfcore/hooks/guard-status-html.sh +5 -1
  21. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  22. package/.tfcore/hooks/yolo-allow.sh +27 -0
  23. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  24. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  25. package/.tfcore/tasks/_develop-mode.md +34 -0
  26. package/.tfcore/tasks/_smoke-test-policy.md +7 -5
  27. package/.tfcore/tasks/_yolo-mode.md +2 -2
  28. package/.tfcore/tasks/amend-docs.md +1 -1
  29. package/.tfcore/tasks/day1-brownfield.md +2 -2
  30. package/.tfcore/tasks/day1-greenfield.md +4 -4
  31. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  32. package/.tfcore/tasks/fix-issues.md +1 -0
  33. package/.tfcore/tasks/mockups.md +1 -1
  34. package/.tfcore/tasks/triage-and-fix.md +2 -2
  35. package/.tfcore/tasks/verify-phase.md +12 -11
  36. package/.tfcore/telemetry/SCHEMA.md +6 -2
  37. package/.tfcore/telemetry/tf-metrics.sh +19 -0
  38. package/.tfcore/templates/stack-defaults/dotnet.md +5 -1
  39. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  40. package/.tfcore/templates/v4custom/app-brd-tmpl.md +2 -1
  41. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  42. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  43. package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +4 -1
  44. package/.tfcore/templates/v4custom/html-render-shell.md +2 -0
  45. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  46. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  47. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  48. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  49. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  50. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  51. package/.tfcore/utils/tf-appium.mjs +179 -0
  52. package/.tfcore/utils/tf-brd-status.py +10 -1
  53. package/.tfcore/utils/tf-build-list.py +16 -1
  54. package/.tfcore/utils/tf-build.sh +75 -0
  55. package/.tfcore/utils/tf-checklist-edit.py +28 -3
  56. package/.tfcore/utils/tf-day1-files.py +55 -28
  57. package/.tfcore/utils/tf-develop.sh +264 -0
  58. package/.tfcore/utils/tf-devguide-list.py +6 -2
  59. package/.tfcore/utils/tf-doc-check.py +124 -21
  60. package/.tfcore/utils/tf-doc-tests.sh +40 -3
  61. package/.tfcore/utils/tf-emit.sh +40 -10
  62. package/.tfcore/utils/tf-fix-close.sh +13 -7
  63. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  64. package/.tfcore/utils/tf-menus.mjs +401 -0
  65. package/.tfcore/utils/tf-mockup-parity.mjs +361 -28
  66. package/.tfcore/utils/tf-mockup-parity.sh +14 -3
  67. package/.tfcore/utils/tf-owner-text.py +27 -5
  68. package/.tfcore/utils/tf-phase.sh +24 -4
  69. package/.tfcore/utils/tf-render-html.py +19 -3
  70. package/.tfcore/utils/tf-req-ids.py +36 -0
  71. package/.tfcore/utils/tf-stack-check.sh +3 -0
  72. package/.tfcore/utils/tf-status-facts.py +90 -15
  73. package/.tfcore/utils/tf-theme.mjs +189 -0
  74. package/.tfcore/utils/tf-triage.py +30 -4
  75. package/.tfcore/utils/tf-verify-boot.sh +296 -21
  76. package/.tfcore/utils/tf-verify-emit.sh +37 -17
  77. package/.tfcore/utils/tf-verify-list.py +219 -20
  78. package/.tfcore/utils/tf-verify-list.sh +3 -1
  79. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  80. package/.tfcore/utils/tf-verify-screens.mjs +184 -20
  81. package/.tfcore/utils/tf-verify-screens.sh +17 -4
  82. package/.tfcore/utils/tf-verify-tests.sh +107 -17
  83. package/.tfcore/utils/tf-verify-verdict.py +118 -15
  84. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  85. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  86. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  87. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  88. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  89. package/.tfcore/utils/tf-yolo.sh +31 -3
  90. package/.tfcore/utils/tf_feedback.py +86 -18
  91. package/.tfcore/utils/tf_stack_check.py +93 -0
  92. package/README.md +2 -2
  93. package/opencode.jsonc +6 -2
  94. package/package.json +1 -1
  95. package/scripts/install.mjs +14 -3
  96. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  97. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  98. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  99. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  100. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  101. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  102. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  103. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  104. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  105. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  106. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -30,6 +30,7 @@ Schema grammar (one `key: value` per line inside the comment):
30
30
  from __future__ import annotations
31
31
 
32
32
  import argparse
33
+ import glob
33
34
  import json
34
35
  import os
35
36
  import re
@@ -70,7 +71,9 @@ DOC_NAME = re.compile(
70
71
  r"^(.+?)(?:-P(\d+))?-(BRD|Deployment-Checklist(?:-[\w]+)?|Checklist|UIDesign|DevGuide|Architecture|Coding-Standards|UsageGuide|Usage-Guide|ProductGuide|Phases)\.md$",
71
72
  re.I)
72
73
  PHASE_FORM = re.compile(r"^(\d+)\s+of\s+(\d+)$", re.I)
73
- PHASE_STATUS = {"planned", "building", "done"}
74
+ # kept-whole: a phase finished before the size cap that the owner chose to keep as one document;
75
+ # its cap and word maximum are reported as WARN, not FAIL (Lekhak TF-029)
76
+ PHASE_STATUS = {"planned", "building", "done", "kept-whole"}
74
77
  BRD_RANGE = re.compile(r"(?:BRD-)?(\d+)\s*(?:to|-|–|—)\s*(?:BRD-)?(\d+)", re.I)
75
78
 
76
79
  SIZE_NAMES = {"s": "S", "small": "S", "m": "M", "medium": "M", "l": "L", "large": "L"}
@@ -96,9 +99,10 @@ NAMES_SCREEN = re.compile(r"\b(on|opens?|from|in)\b", re.I)
96
99
 
97
100
 
98
101
  def acceptance_words(line: str) -> int:
99
- """Words in an acceptance line, without the "Acceptance:" label and without a perf-budget tail."""
102
+ """Words in an acceptance line, without the "Acceptance:" label and without a perf-budget or
103
+ zoom-check tail (the markers the verifier reads)."""
100
104
  t = re.sub(r"^\s*[-*]\s*\*?Acceptance:?\*?:?\s*", "", line)
101
- t = re.split(r"perf-budget:", t, flags=re.I)[0]
105
+ t = re.split(r"perf-budget:|zoom-check:", t, flags=re.I)[0]
102
106
  return len(re.findall(r"\S+", t))
103
107
 
104
108
 
@@ -331,7 +335,7 @@ def read_core_config(root: str) -> dict:
331
335
  if os.path.exists(p):
332
336
  with open(p, encoding="utf-8") as fh:
333
337
  for line in fh:
334
- m = re.match(r"^(appSize|appKind|appPhase):\s*(\S+)", line)
338
+ m = re.match(r"^(appSize|appKind|appPhase):\s*(\S+)", line) or re.match(r"^\s+(project_type):\s*(\S+)", line)
335
339
  if m and m.group(2) not in ("null", "~", "''", '""'):
336
340
  out[m.group(1)] = m.group(2).strip("'\"")
337
341
  return out
@@ -356,6 +360,24 @@ def foreign_prefix(path: str):
356
360
  return app if app and apps and app.lower() not in apps else None
357
361
 
358
362
 
363
+ def phase_status(path: str, app: str, phase: int):
364
+ """The Status cell of this phase's row in docs/{App}-Phases.md beside it, lower-cased; None when
365
+ there is no Phases document or no row for the phase."""
366
+ p = os.path.join(os.path.dirname(path), f"{app}-Phases.md")
367
+ if not app or not os.path.isfile(p):
368
+ return None
369
+ with open(p, encoding="utf-8") as fh:
370
+ txt = strip_noise(fh.read())
371
+ for header, rows in tables_in(txt):
372
+ low = [h.lower() for h in header]
373
+ if "phase" in low and "status" in low:
374
+ ip, ist = low.index("phase"), low.index("status")
375
+ for r in rows:
376
+ if len(r) > max(ip, ist) and r[ip].strip("`* ") == str(phase):
377
+ return r[ist].strip("`* ").lower()
378
+ return None
379
+
380
+
359
381
  def phase_file(app: str, phase: int, suffix: str) -> str:
360
382
  return f"{app}-{suffix}.md" if phase <= 1 else f"{app}-P{phase}-{suffix}.md"
361
383
 
@@ -391,6 +413,17 @@ def resolve_kind(header, brd_header, cfg) -> str:
391
413
  return "library" if raw.startswith("lib") else "app"
392
414
 
393
415
 
416
+ def signs_in_through_appmanager(root, app):
417
+ """the Architecture's authentication row (Q4, Auth…, Identity) names AppManager"""
418
+ for f in glob.glob(os.path.join(root or ".", "docs", f"{app}-Architecture.md")):
419
+ for _h, rows in tables_in(open(f, encoding="utf-8", errors="replace").read()):
420
+ for r in rows:
421
+ head = " ".join(r[:2]).lower()
422
+ if re.search(r"\bq4\b|auth|identity|sign-in", head) and re.search(r"(?i)app ?manager", " ".join(r[1:3])):
423
+ return True
424
+ return False
425
+
426
+
394
427
  def section_text(present, name):
395
428
  key = norm_heading(name)
396
429
  return next((txt for k, _h, txt in present if key_matches(key, k)), None)
@@ -450,6 +483,10 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
450
483
  rep.fail(rel, f'a phase-{phase} file needs the header row "Phase | {phase} of <m>"')
451
484
  elif os.path.exists(phases_doc):
452
485
  rep.fail(rel, f'the project has phases ({os.path.relpath(phases_doc, root)}); this file needs the header row "Phase | 1 of <m>"')
486
+ # a phase the owner kept whole past the cap: its size findings are WARN, never a gate failure (Lekhak TF-029)
487
+ kept = doc in PHASED_DOCS and bool(app) and phase_status(path, app, phase) == "kept-whole"
488
+ size_finding = rep.warn if kept else rep.fail
489
+ kept_why = f"; phase {phase} is kept-whole in {app}-Phases.md, so this is reported, not blocking" if kept else ""
453
490
 
454
491
  # 2. sections: strangers, order, presence
455
492
  present = [(norm_heading(h), h, txt) for h, txt in split_sections(clean, 2) if h is not None]
@@ -502,7 +539,7 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
502
539
  target, mx = target + extra * schema.per_entry[0], mx + extra * schema.per_entry[1]
503
540
  why = f", with {extra} entries past the cap of {SCREEN_CAP[size]}" if extra else ""
504
541
  if total > mx:
505
- rep.fail(rel, f"{total:,} words; the {SIZE_LONG[size]} maximum is {mx:,} (target {target:,}{why}). Shorten prose; never drop a screen, field or requirement to fit")
542
+ size_finding(rel, f"{total:,} words; the {SIZE_LONG[size]} maximum is {mx:,} (target {target:,}{why}). Shorten prose; never drop a screen, field or requirement to fit{kept_why}")
506
543
  elif total > target:
507
544
  rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,}{why})")
508
545
  for key, h, txt in present:
@@ -564,7 +601,7 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
564
601
  # 5. document rules
565
602
  ctx = dict(doc=doc, body=body, clean=clean, nocomment=nocomment, present=present, header=header,
566
603
  size=size, kind=kind, root=root, path=path, rel=rel, entry_names=entry_names,
567
- app=app, phase=phase)
604
+ app=app, phase=phase, size_finding=size_finding, kept_why=kept_why)
568
605
  for rule in schema.rules:
569
606
  if not rule.startswith("entry-"):
570
607
  check_doc_rule(rule, ctx, rep)
@@ -653,7 +690,7 @@ def check_doc_rule(rule, c, rep):
653
690
  if dupes:
654
691
  rep.fail(rel, "duplicate requirement ids: " + ", ".join(f"BRD-{d}" for d in sorted(dupes, key=int)))
655
692
  if len(seen) > REQ_CAP[size]:
656
- rep.fail(rel, f"{len(seen)} requirements; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases (each phase its own BRD) instead of growing this one")
693
+ c["size_finding"](rel, f"{len(seen)} requirements; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases (each phase its own BRD) instead of growing this one{c['kept_why']}")
657
694
  c["brd_ids"] = seen
658
695
  req = section_text(present, "Requirements") or ""
659
696
  cur = None
@@ -774,6 +811,35 @@ def check_doc_rule(rule, c, rep):
774
811
  if txt is not None and not has_table_with(txt, "user", "role"):
775
812
  rep.fail(rel, 'the "Test users" section needs a table with User and Role columns')
776
813
 
814
+ elif rule == "appmanager-test-users":
815
+ # An app that signs in through AppManager has its own three test users there, registered through
816
+ # AppManager's register call, each password kept in the git-ignored secrets and never written in
817
+ # the guide; never the owner's personal account (stack answer Q4a, owner 2026-10-18).
818
+ txt = section_text(present, "Test users")
819
+ app = c.get("app")
820
+ if txt is None or c["kind"] != "app" or not app or app.lower() == "appmanager" or not signs_in_through_appmanager(c["root"], app):
821
+ return
822
+ users = []
823
+ for hdr, rows in tables_in(txt):
824
+ low = [h.lower() for h in hdr]
825
+ if not any("user" in h for h in low):
826
+ continue
827
+ pw = next((i for i, h in enumerate(low) if "password" in h), None)
828
+ users += [(r, r[pw] if pw is not None and pw < len(r) else "") for r in rows if "@" in " ".join(r)]
829
+ whole = lambda r: " ".join(r).lower()
830
+ want = (("owner", r"owner|account holder"), ("second user", r"second"), ("unattended-run user", r"unattended|yolo"))
831
+ missing = [n for n, pat in want if not any(re.search(pat, whole(r)) for r, _p in users)]
832
+ if missing:
833
+ rep.fail(rel, f'{app} signs in through AppManager: the Test users table lists no {", no ".join(missing)} '
834
+ f'(stack answer Q4a: owner walkthrough, second user, unattended runs, registered through AppManager\'s register call)')
835
+ for r, cell in users:
836
+ if not re.search(r"(?i)secret|\.env|credential|keychain", cell):
837
+ who = re.search(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)*", " ".join(r)).group(0)
838
+ rep.fail(rel, f'test user {who}: the Password cell names no git-ignored secret; keep the password in the project\'s '
839
+ f'secrets and name the key here, never the password (stack answer Q4a)')
840
+ if users and not re.search(r"(?i)regist", txt):
841
+ rep.fail(rel, "the Test users section does not say the users were registered through AppManager's register call (stack answer Q4a)")
842
+
777
843
  elif rule == "next-command-blocks":
778
844
  seg = re.search(r"(?ms)^## Next command to run\s*\n(.*?)(?=^## |\Z)", nocomment)
779
845
  if not seg:
@@ -986,7 +1052,7 @@ def check_doc_rule(rule, c, rep):
986
1052
  rep.fail(rel, f"phase {n} BRD range runs backwards ({x} to {y})")
987
1053
  st = r[ist].strip("`* ").lower()
988
1054
  if st not in PHASE_STATUS:
989
- rep.fail(rel, f'phase {n} status "{r[ist]}" must be planned, building or done')
1055
+ rep.fail(rel, f'phase {n} status "{r[ist]}" must be planned, building, done or kept-whole')
990
1056
  rows_out.append(dict(n=n, name=r[iname].strip("`* "), screens=screens, lo=lo, hi=hi, ranges=ranges, status=st))
991
1057
  nums = [p["n"] for p in rows_out]
992
1058
  if nums != list(range(1, len(nums) + 1)):
@@ -1033,10 +1099,12 @@ def check_checklist(c, rep):
1033
1099
  row_status[rid] = status.strip("`* ").lower()
1034
1100
  if not re.fullmatch(r"REQ-(UI|FN|RAG|NFR)-\d{3}", rid):
1035
1101
  rep.fail(rel, f'id "{rid}" is not REQ-UI/FN/RAG/NFR- plus three digits')
1102
+ removed = " A removed row reads N/A with % 0, and the date goes in Remarks"
1036
1103
  if status.strip("`* ").lower() not in STATUS_VALUES:
1037
- rep.fail(rel, f'{rid} status "{status}" is not one of the fixed values')
1104
+ rep.fail(rel, f'{rid} status "{status}" is not one of the fixed values.'
1105
+ + (removed if status.strip("`* ").lower().startswith("n/a") else ""))
1038
1106
  if pct.strip("`* ").rstrip("%").strip() not in ("0", "25", "50", "75", "100"):
1039
- rep.fail(rel, f'{rid} % "{pct}" must be 0, 25, 50, 75 or 100')
1107
+ rep.fail(rel, f'{rid} % "{pct}" must be 0, 25, 50, 75 or 100.' + removed)
1040
1108
  n = len(re.findall(r"\S+", remarks))
1041
1109
  if n > 60:
1042
1110
  rep.fail(rel, f"{rid} Remarks is {n} words; at most 60, current state only (history lives in the telemetry streams)")
@@ -1046,6 +1114,10 @@ def check_checklist(c, rep):
1046
1114
  said = re.sub(r"`[^`]*(?:`|$)", " ", remarks)
1047
1115
  if NOT_PRESENT.search(said) and not NAMES_PATH.search(remarks):
1048
1116
  rep.fail(rel, f"{rid} Remarks says something is not present without naming the path that was tried; search tools skip .tfcore/, so read the literal path first")
1117
+ if row_status[rid] == "n/a":
1118
+ # a removed or reserved row is kept for traceability; it has no screen, mockup or BRD item to
1119
+ # point at, so the detail-entry rules do not apply to it (Lekhak TF-028)
1120
+ continue
1049
1121
  m = re.search(r"\(#([^)]+)\)", details)
1050
1122
  if not m:
1051
1123
  rep.fail(rel, f"{rid} Details cell has no link to its detail entry")
@@ -1054,7 +1126,7 @@ def check_checklist(c, rep):
1054
1126
  if not ids:
1055
1127
  rep.fail(rel, "the Requirements Status table has no REQ- rows")
1056
1128
  if len(ids) > REQ_CAP[size]:
1057
- rep.fail(rel, f"{len(ids)} rows; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases instead of growing this checklist")
1129
+ c["size_finding"](rel, f"{len(ids)} rows; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases instead of growing this checklist{c['kept_why']}")
1058
1130
  dupes = sorted({i for i in ids if ids.count(i) > 1})
1059
1131
  if dupes:
1060
1132
  rep.fail(rel, "duplicate rows: " + ", ".join(dupes))
@@ -1076,6 +1148,9 @@ def check_checklist(c, rep):
1076
1148
  start = pos + own.end()
1077
1149
  nxt = re.search(r"\n\s*(?:[-*]\s*)?<a id=|\n## |\n### ", body[start:]) # an entry is a list item: the dash precedes the anchor (miss 21)
1078
1150
  entry = body[pos: start + nxt.start()] if nxt else body[pos:]
1151
+ if row_status.get(rid) == "n/a":
1152
+ brd_refs.update(re.findall(r"BRD-(\d+)", entry)) # a retired BRD item still has its row
1153
+ continue
1079
1154
  acc = [l for l in entry.splitlines() if ACCEPT_LINE.search(l)]
1080
1155
  if len(acc) != 1:
1081
1156
  rep.fail(rel, f"{rid} must have exactly one acceptance line; found {len(acc)}")
@@ -1100,7 +1175,10 @@ def check_checklist(c, rep):
1100
1175
  brd_refs.update(refs)
1101
1176
  if rid.startswith("REQ-UI"):
1102
1177
  links = links_to(entry, "mockups")
1103
- if not links:
1178
+ # a library or docs project (metrics.project_type, or kind: library) has no screens and no
1179
+ # docs/mockups/: its UI rows map to components, so a missing link is not a finding there; a
1180
+ # link it does carry must still resolve (TrBlazeUI TF-003)
1181
+ if not links and c.get("kind") == "app" and read_core_config(root).get("project_type", "app").lower() not in ("library", "docs"):
1104
1182
  rep.fail(rel, f"{rid} is a UI row without a mockup link")
1105
1183
  for l in sorted(links):
1106
1184
  if not resolve(root, path, l):
@@ -1275,14 +1353,18 @@ NAV_ITEM = re.compile(r"""<(div|li|span|button)\b[^>]*\bclass\s*=\s*["'][^"']*(?
1275
1353
 
1276
1354
 
1277
1355
  def _mock_target(mock_dir: str, t: str):
1278
- """Resolve a link the way a browser opening docs/mockups/<file> would. Returns the file
1279
- name when it lands on an existing file inside the mockup folder, else None."""
1356
+ """Resolve a link the way a browser opening docs/mockups/<file> would. Returns the path
1357
+ relative to the mockup folder when it lands on an existing file inside it, at any depth
1358
+ (Lekhak TF-008: index.html linking to admin/connection-settings.html opens, and was
1359
+ failed), else None. A leading slash, or a path that leaves docs/mockups/, is still None."""
1280
1360
  if re.match(r"^(https?:|mailto:|tel:|javascript:|data:)", t, re.I) or t.startswith("/"):
1281
1361
  return None
1362
+ t = re.split(r"[?#]", t, maxsplit=1)[0]
1363
+ base = os.path.normpath(mock_dir)
1282
1364
  p = os.path.normpath(os.path.join(mock_dir, t))
1283
- if os.path.dirname(p) != os.path.normpath(mock_dir) or not os.path.isfile(p):
1365
+ if not (p == base or p.startswith(base + os.sep)) or not os.path.isfile(p):
1284
1366
  return None
1285
- return os.path.basename(p)
1367
+ return os.path.relpath(p, base).replace(os.sep, "/")
1286
1368
 
1287
1369
 
1288
1370
  def check_mockups(root: str, rep: Report):
@@ -1315,7 +1397,8 @@ def check_mockups(root: str, rep: Report):
1315
1397
  continue
1316
1398
  name = _mock_target(mock_dir, t)
1317
1399
  if name is None:
1318
- hint = "write the file name alone, not a folder or a leading slash" if ("/" in t) else "the file does not exist"
1400
+ hint = ("a leading slash opens from the disk's root; write the path from docs/mockups/" if t.startswith("/")
1401
+ else "the file does not exist inside docs/mockups/")
1319
1402
  rep.fail(rel, f'links to "{t}", which does not open from docs/mockups/ ({hint})')
1320
1403
  else:
1321
1404
  targets.append(name)
@@ -1420,6 +1503,7 @@ def main(argv=None):
1420
1503
  if a.app:
1421
1504
  root = root or os.getcwd()
1422
1505
  files += app_files(root, a.app)
1506
+ files = list(dict.fromkeys(os.path.abspath(f) for f in files)) # a file given and listed by --app once
1423
1507
  if not files:
1424
1508
  print(f"tf-doc-check: no documents for app {a.app} under {root}", file=sys.stderr)
1425
1509
  return 2
@@ -1452,9 +1536,16 @@ def main(argv=None):
1452
1536
  base = json.load(fh) or {}
1453
1537
  except Exception:
1454
1538
  base = {}
1455
- for f in files:
1456
- r = os.path.relpath(f, root)
1457
- base[r] = sorted({l.split(": ", 1)[1] for l in rep.lines if l.startswith("FAIL ") and l.split(": ", 1)[0].endswith(" " + r)})
1539
+ # every finding, under whatever it names: the mockup-folder and cross-document checks name
1540
+ # docs/mockups/index.html or another document, not one of the files given, and were never
1541
+ # recorded, so ~60 old findings read as new on every *amend-docs (Lekhak TF-023)
1542
+ new = {os.path.relpath(f, root): set() for f in files}
1543
+ for l in rep.lines:
1544
+ if l.startswith("FAIL ") and ": " in l:
1545
+ head, msg = l.split(": ", 1)
1546
+ new.setdefault(head[len("FAIL "):], set()).add(msg)
1547
+ for r, msgs in new.items():
1548
+ base[r] = sorted(msgs)
1458
1549
  os.makedirs(os.path.dirname(base_path), exist_ok=True)
1459
1550
  with open(base_path, "w", encoding="utf-8") as fh:
1460
1551
  json.dump(base, fh, indent=1)
@@ -1468,11 +1559,23 @@ def main(argv=None):
1468
1559
  with open(base_path, encoding="utf-8") as fh:
1469
1560
  base = json.load(fh) or {}
1470
1561
  relined = []
1562
+ # A size finding carries its count ("20,677 words; the Small maximum is 8,000"), so any
1563
+ # edit to an over-limit document, a cut included, changed the text and read as new
1564
+ # (Lekhak TF-023). The same finding with a count no larger than the old one is old;
1565
+ # a document that grew past its maximum still FAILs.
1566
+ def _shape(m):
1567
+ return re.sub(r"\d[\d,]*", "#", m)
1568
+ def _first(m):
1569
+ n = re.search(r"\d[\d,]*", m)
1570
+ return int(n.group(0).replace(",", "")) if n else None
1471
1571
  for l in rep.lines:
1472
1572
  if l.startswith("FAIL "):
1473
1573
  head, msg = l.split(": ", 1)
1474
1574
  r = head[len("FAIL "):]
1475
- if msg in set(base.get(r, [])):
1575
+ was = base.get(r, [])
1576
+ same = msg in set(was) or (re.match(r"^\d[\d,]* (words|lines|rows|requirements|KB|screens)", msg) and any(
1577
+ _shape(o) == _shape(msg) and _first(o) is not None and _first(msg) <= _first(o) for o in was))
1578
+ if same:
1476
1579
  relined.append("OLD " + l[5:])
1477
1580
  rep.fails -= 1
1478
1581
  old += 1
@@ -7,7 +7,8 @@
7
7
  # docs/Lekhak-UsageGuide.md and fails when the REQ-NFR-042 paragraph is gone. *amend-docs removed that
8
8
  # paragraph, ran no test, closed green, and only CI noticed. This script looks for test source files
9
9
  # (a path containing "test") that name one of the documents, by file name or by name without ".md".
10
- # When one does, it runs the unit tests through tf-build.sh test. Prints ONE line:
10
+ # When one does, it runs the test projects holding those files, each on its own through tf-build.sh
11
+ # test (TechieRag TF-004), or the solution's tests one project at a time. Prints ONE line:
11
12
  # NONE no test reads <docs>; nothing to run exit 0
12
13
  # PASS <n> test file(s) read <docs> (<files>); the unit tests pass — <build line> exit 0
13
14
  # FAIL <n> test file(s) read <docs> (<files>); the unit tests fail — <build line> exit 1
@@ -20,7 +21,7 @@ DOCS=(); TARGET=""
20
21
  while [[ $# -gt 0 ]]; do
21
22
  case "$1" in
22
23
  --target) TARGET="${2:-}"; shift 2 ;;
23
- -h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
24
+ -h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
24
25
  *) DOCS+=("$1"); shift ;;
25
26
  esac
26
27
  done
@@ -71,7 +72,43 @@ if [[ -z "$TARGET" && -z "$ROOTSLN" ]]; then
71
72
  fi
72
73
  exit 2
73
74
  fi
74
- LINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
75
+ # Only the test projects that hold a reader, each on its own (TechieRag TF-004): the whole solution at
76
+ # once let the local-model tests take the memory, and TechieRag.Tests failed with "Cannot allocate
77
+ # memory" on a test that passes alone. A reader in no .NET test project (a browser spec) falls back to
78
+ # the solution run, which tf-build.sh also splits into one test project at a time.
79
+ OWNERS="$(TF_READERS="$READERS" python3 - <<'PY'
80
+ import glob, os, sys
81
+ out = []
82
+ for r in os.environ.get("TF_READERS", "").split("\n"):
83
+ if not r.strip():
84
+ continue
85
+ d = os.path.dirname(r)
86
+ while d and d not in (".", "/"):
87
+ projs = glob.glob(os.path.join(d, "*.csproj"))
88
+ if projs:
89
+ p = projs[0]
90
+ if p not in out:
91
+ out.append(p)
92
+ break
93
+ d = os.path.dirname(d)
94
+ print("\n".join(out))
95
+ PY
96
+ )"
97
+ if [[ -n "$OWNERS" && -z "$TARGET" ]]; then
98
+ fails=(); notrun=(); passed=0; vl=()
99
+ while IFS= read -r p; do
100
+ [[ -z "$p" ]] && continue
101
+ v="$(bash "$HERE/tf-build.sh" test "$p" 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
102
+ vl+=("$(basename "$p" .csproj): ${v:-no verdict}")
103
+ case "$v" in PASS*) passed=$((passed+1)) ;; FAIL*) fails+=("$(basename "$p" .csproj)") ;; *) notrun+=("$(basename "$p" .csproj)") ;; esac
104
+ done <<<"$OWNERS"
105
+ k="$(grep -c . <<<"$OWNERS")"
106
+ if [[ ${#fails[@]} -gt 0 ]]; then LINE="FAIL ${fails[*]} failed ($k test project(s) holding a reader, each run on its own) — ${vl[*]}"
107
+ elif [[ ${#notrun[@]} -gt 0 ]]; then LINE="NOT-RUN ${notrun[*]} could not run — ${vl[*]}"
108
+ else LINE="PASS $k test project(s) holding a reader, each run on its own — ${vl[*]}"; fi
109
+ else
110
+ LINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
111
+ fi
75
112
  case "$LINE" in
76
113
  PASS*) echo "PASS $N test file(s) read ${DOCS[*]} ($LIST); the unit tests pass — $LINE"; exit 0 ;;
77
114
  FAIL*) echo "FAIL $N test file(s) read ${DOCS[*]} ($LIST); the unit tests fail — $LINE"; exit 1 ;;
@@ -1015,16 +1015,34 @@ def _window_opencode(t0, t1):
1015
1015
  return None
1016
1016
  dbp = ptr.get("db_path")
1017
1017
  sid = ptr.get("session_id")
1018
- if not dbp or not sid or not os.path.isfile(dbp):
1018
+ if not dbp or not os.path.isfile(dbp):
1019
1019
  return None
1020
1020
  try:
1021
1021
  import sqlite3
1022
1022
  db = sqlite3.connect("file:%s?mode=ro" % dbp, uri=True)
1023
- pairs = db.execute("SELECT id, parent_id FROM session").fetchall()
1023
+ # OpenCode 2 keeps sessions in session_v2 and messages in session_message; the old tables
1024
+ # stopped on 2026-09-27, so every OpenCode record since read tokens_scope "none". Both are read.
1025
+ tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
1026
+ pairs = db.execute("SELECT id, parent_id FROM session").fetchall() if "session" in tables else []
1027
+ if "session_v2" in tables:
1028
+ pairs += db.execute("SELECT id, parent_id FROM session_v2").fetchall()
1024
1029
  kids = {}
1025
1030
  for i, p in pairs:
1026
1031
  kids.setdefault(p, []).append(i)
1027
- tree, queue = {sid}, [sid]
1032
+ # The roots: the pointer's session, and every root session OpenCode 2 recorded in THIS project's
1033
+ # folder. Each *develop-end-to-end phase runs in a new session and the plugin's pointer kept the
1034
+ # first one, so the later phases read "none" (2026-10-08). Messages are still taken only inside
1035
+ # the record's own window.
1036
+ roots = {sid} if sid else set()
1037
+ if "session_v2" in tables:
1038
+ here = os.path.realpath(root)
1039
+ for i, p_, dr in db.execute("SELECT id, parent_id, directory FROM session_v2"):
1040
+ if not p_ and dr and os.path.realpath(dr) == here:
1041
+ roots.add(i)
1042
+ if not roots:
1043
+ db.close()
1044
+ return None
1045
+ tree, queue = set(roots), list(roots)
1028
1046
  while queue:
1029
1047
  for c in kids.get(queue.pop(), []):
1030
1048
  if c not in tree:
@@ -1036,15 +1054,20 @@ def _window_opencode(t0, t1):
1036
1054
  # Same rule as the Claude branch: a CHILD session counts as a subagent run
1037
1055
  # for THIS window only if it produced output inside it.
1038
1056
  child_out = {}
1039
- q = "SELECT session_id, data FROM message WHERE time_created BETWEEN ? AND ?"
1040
- for msid, data in db.execute(q, (t0, t1)):
1057
+ rows = []
1058
+ if "message" in tables:
1059
+ rows += db.execute("SELECT session_id, data FROM message WHERE time_created BETWEEN ? AND ?", (t0, t1)).fetchall()
1060
+ if "session_message" in tables:
1061
+ rows += [(a, b) for a, b in db.execute(
1062
+ "SELECT session_id, data FROM session_message WHERE type = 'assistant' AND time_created BETWEEN ? AND ?", (t0, t1))]
1063
+ for msid, data in rows:
1041
1064
  if msid not in tree:
1042
1065
  continue
1043
1066
  try:
1044
1067
  d = json.loads(data)
1045
1068
  except Exception:
1046
1069
  continue
1047
- if d.get("role") != "assistant":
1070
+ if d.get("role") not in (None, "assistant"):
1048
1071
  continue
1049
1072
  t = d.get("tokens") or {}
1050
1073
  cache = t.get("cache") or {}
@@ -1054,9 +1077,11 @@ def _window_opencode(t0, t1):
1054
1077
  tot["cr"] += cache.get("read") or 0
1055
1078
  tot["cw"] += cache.get("write") or 0
1056
1079
  cost += d.get("cost") or 0
1057
- if msid != sid and o:
1080
+ if msid not in roots and o:
1058
1081
  child_out[msid] = child_out.get(msid, 0) + o
1059
- m = (d.get("providerID", "") + "/" if d.get("providerID") else "") + (d.get("modelID") or "")
1082
+ mo = d.get("model") if isinstance(d.get("model"), dict) else {}
1083
+ prov = d.get("providerID") or mo.get("providerID") or ""
1084
+ m = (prov + "/" if prov else "") + (d.get("modelID") or mo.get("id") or "")
1060
1085
  if m:
1061
1086
  models[m] = models.get(m, 0) + o
1062
1087
  db.close()
@@ -1463,7 +1488,7 @@ _ALLOWED = {
1463
1488
  "routed", "tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write",
1464
1489
  "cost_usd", "tokens_scope", "attempt", "subagent_runs", "tokens_out_subagents",
1465
1490
  "model_tokens_out", "session_id", "usage_start_event_ts", "usage_end_event_ts",
1466
- "billing_mode", "cost_source", "fallback_from"},
1491
+ "billing_mode", "cost_source", "fallback_from", "app_phase"},
1467
1492
  # A correction, not a deletion: it names a run record that is wrong by the pair every
1468
1493
  # run carries (`cmd` + `started`) and says why, so every figure can skip it while the
1469
1494
  # record itself stays where it is. Written by --void-run, which refuses one that names
@@ -1474,7 +1499,7 @@ _ALLOWED = {
1474
1499
  "tier", "tier_model", "model", "models", "tokens_in", "tokens_out", "tokens_cache_read",
1475
1500
  "tokens_cache_write", "cost_usd", "tokens_scope", "model_tokens_out", "routed",
1476
1501
  "subagent_runs", "tokens_out_subagents", "session_id",
1477
- "billing_mode", "cost_source", "fallback_from"},
1502
+ "billing_mode", "cost_source", "fallback_from", "app_phase"},
1478
1503
  ("sessions", "session"): {"session_id", "model", "duration_s", "input_tokens", "output_tokens",
1479
1504
  "cache_read_tokens", "cache_creation_tokens", "cost_usd", "children_sessions"},
1480
1505
  ("commits", "commit"): {"sha", "files", "insertions", "deletions", "subject_prefix", "branch"},
@@ -1519,6 +1544,11 @@ def _shape_ok(rec):
1519
1544
  if kind == "run" and rec.get("build_result") not in (None, "pass", "fail", "not-run"):
1520
1545
  sys.stdout.write("tf-emit: REFUSED — build_result must be pass, fail or not-run (SCHEMA.md §2); got %r. Put the detail in the checklist Remarks or Known blockers, not here. Nothing was appended.\n" % (rec.get("build_result"),))
1521
1546
  return False
1547
+ # one REQ's verdict, never a suite note: Lekhak hand-wrote four {"gate":"verify-suite","result":…}
1548
+ # records with neither, and the report counted each as a failure (TF-020)
1549
+ if kind == "gate" and not rec.get("backfilled") and not (rec.get("req_id") and rec.get("verdict")):
1550
+ sys.stdout.write("tf-emit: REFUSED — a gate record is one REQ's verdict and needs both req_id and verdict (SCHEMA.md §3). A suite result (unit tests, the whole browser run) belongs in the run record's notes, not here. Nothing was appended.\n")
1551
+ return False
1522
1552
  if kind == "miss" and not rec.get("miss_id"):
1523
1553
  sys.stdout.write("tf-emit: REFUSED — a miss record needs a miss_id; get one with: bash .tfcore/utils/tf-emit.sh --next-miss-id. Nothing was appended.\n")
1524
1554
  return False
@@ -3,6 +3,7 @@
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
+ # [--cmd triage-and-fix] # the command the run record is filed under
6
7
  # bash .tfcore/utils/tf-fix-close.sh <App> --misses MISS-App-20260901-06,MISS-App-20260830-03 \
7
8
  # --fix-cmd amend-docs [--verdict Verified]
8
9
  #
@@ -24,13 +25,16 @@
24
25
  # re-verify). A row no verify graded gets no miss-fix and is listed. A row with no open miss is listed,
25
26
  # not invented: a fix on a defect nobody logged is a triage gap, so open it first with tf-triage.sh
26
27
  # demote or tf-log-miss.sh, then re-run this.
28
+ # --cmd names the command the run record is filed under (default fix-issues): *triage-and-fix passes
29
+ # --cmd triage-and-fix, so its run is counted as itself and no second record over the same window is
30
+ # needed, which the overlap rule refused (TrBlazeUI TF-004). The miss-fix records keep fix_cmd fix-issues.
27
31
  # --reqs defaults to every row those verifies graded. The start defaults to the fix's marker, or its
28
32
  # "outer" entry when a chained verify replaced it. Telemetry never blocks: refusals are printed, exit 0.
29
33
  set -u
30
34
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
31
35
  [[ $# -ge 1 && "$1" != "-h" && "$1" != "--help" ]] || { sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 3; }
32
36
  APP="$1"; shift
33
- STARTED=""; REQS=""; SUBS=""; BUILD="pass"; FILES=""; MISSES=""; FIXCMD="fix-issues"; VERDICT=""
37
+ STARTED=""; REQS=""; SUBS=""; BUILD="pass"; FILES=""; MISSES=""; FIXCMD="fix-issues"; VERDICT=""; RUNCMD="fix-issues"
34
38
  while [[ $# -gt 0 ]]; do
35
39
  case "$1" in
36
40
  --started) STARTED="${2:-}"; shift 2 ;;
@@ -41,6 +45,7 @@ while [[ $# -gt 0 ]]; do
41
45
  --misses) MISSES="${2:-}"; shift 2 ;;
42
46
  --fix-cmd) FIXCMD="${2:-}"; shift 2 ;;
43
47
  --verdict) VERDICT="${2:-}"; shift 2 ;;
48
+ --cmd) RUNCMD="${2:-}"; shift 2 ;;
44
49
  *) echo "tf-fix-close: unknown argument $1" >&2; exit 3 ;;
45
50
  esac
46
51
  done
@@ -51,9 +56,10 @@ except Exception: m={}
51
56
  o=m.get('outer') or {}
52
57
  print(m.get('started','') if m.get('cmd')!='verify-phase' or not o.get('started') else o['started'])" 2>/dev/null)"
53
58
  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'
59
+ TF_MISSES="$MISSES" TF_FIXCMD="$FIXCMD" TF_RUNCMD="$RUNCMD" TF_VERDICT="$VERDICT" TF_EMIT="$HERE/tf-emit.sh" python3 - <<'PY'
55
60
  import datetime, json, os, subprocess
56
61
  EMIT = os.environ["TF_EMIT"]; app = os.environ["TF_APP"]; started = os.environ["TF_STARTED"]
62
+ cmd = os.environ.get("TF_RUNCMD") or "fix-issues"
57
63
  now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
58
64
  def q(*a): return subprocess.run(["bash", EMIT, *a], capture_output=True, text=True).stdout.strip()
59
65
  def emit(stream, rec):
@@ -121,7 +127,7 @@ inner, voided = [], set()
121
127
  for r in jsonl("runs.jsonl"):
122
128
  if r.get("kind") == "run-void":
123
129
  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") \
130
+ elif r.get("kind", "run") == "run" and r.get("app") == app and r.get("cmd") not in ("fix-issues", cmd) and not r.get("backfilled") \
125
131
  and started and (r.get("started") or "") >= started and (r.get("ended") or "") and r["ended"] <= now:
126
132
  inner.append((r["started"], r["ended"], r.get("cmd")))
127
133
  inner = sorted(w for w in inner if (w[2], w[0]) not in voided)
@@ -130,11 +136,11 @@ for s, e, _c in inner:
130
136
  if t and s > t: gaps.append((t, s))
131
137
  t = max(t, e) if t else e
132
138
  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"}
139
+ existing = {r.get("started") for r in jsonl("runs.jsonl") if r.get("kind", "run") == "run" and r.get("cmd") == cmd}
134
140
  wrote = had = 0
135
141
  for i, (s, e) in enumerate(gaps):
136
142
  first = i == 0
137
- run = {"kind": "run", "app": app, "cmd": "fix-issues", "mode": "fix", "ended": e, "reqs_touched": reqs if first else [],
143
+ run = {"kind": "run", "app": app, "cmd": cmd, "mode": "fix", "ended": e, "reqs_touched": reqs if first else [],
138
144
  "reqs_count": len(reqs) if first else 0, "subagents": subs if first else [],
139
145
  "files_written": (int(files) if files.isdigit() else len(reqs)) if first else 0, "build_result": os.environ["TF_BUILD"]}
140
146
  if s: run["started"] = s
@@ -143,7 +149,7 @@ for i, (s, e) in enumerate(gaps):
143
149
  had += 1; continue
144
150
  wrote += emit("runs", run)
145
151
  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)")
152
+ print(f"{cmd}: recorded around {len(inner)} chained run(s) ({', '.join(sorted({c for _s, _e, c in inner}))}) in {len(gaps)} segment(s)")
147
153
  closed, none, unverified = 0, [], []
148
154
  for rid in reqs:
149
155
  if rid not in rows:
@@ -158,7 +164,7 @@ for rid in reqs:
158
164
  if started: rec["fix_run_id"] = started
159
165
  if emit("misses", rec): closed += 1
160
166
  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"
167
+ print(f"{cmd}: run record {state}; {closed} miss-fix record(s) with the verifier's verdict"
162
168
  + (f"; no open miss on {', '.join(none)} (log it first, then re-run)" if none else "")
163
169
  + (f"; no verify graded {', '.join(unverified)} during this fix, so its miss stays open with no miss-fix" if unverified else ""))
164
170
  PY