@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
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # tf-ci-repro.sh — reproduce a CI job here the way a fresh runner runs it (Lekhak TF-019, 2026-09-30).
3
+ #
4
+ # bash .tfcore/utils/tf-ci-repro.sh [<workflow.yml>] [--job <id>] [--list] [--keep] [--run-setup]
5
+ #
6
+ # Run from the repository root. A developer machine hides CI failures in its package caches and its
7
+ # build output: Lekhak's CI failed with NETSDK1112 because the win-x64 runtime pack was never
8
+ # downloaded, while the same commands passed locally where the pack was already cached. A local run
9
+ # with warm caches is therefore no proof that a CI failure is fixed. This script copies the repository
10
+ # without anything .gitignore ignores, to a folder outside it, points every package cache it knows
11
+ # (NuGet, npm, yarn, pnpm, pip, Go, Gradle, Maven) at an empty folder, and runs the job's `run:` steps
12
+ # in the runner's shell, stopping at the first failure. A Windows job runs Windows-side from WSL.
13
+ # `uses:` steps, steps that need a secret and steps that only install tools on the machine are
14
+ # skipped, each with the reason. --list prints the plan and runs nothing; --keep keeps the copy.
15
+ # Prints ONE verdict line last:
16
+ # PASS job <id>: <n> run step(s) passed on a clean copy with empty caches (…) — logs <dir> exit 0
17
+ # FAIL job <id>: step <n> "<name>" failed (exit N) with empty caches — <first error> — log <file> exit 1
18
+ # NOT-RUN <why: no workflow, several jobs, a macOS job off a Mac …> exit 2
19
+ # PyYAML is used when installed; without it (a stock Mac) a built-in reader takes the workflow.
20
+ # Logs: tests/.artifacts/ci-repro/<UTC time>/step-NN.log. The first run downloads every package, so
21
+ # it takes as long as a cold CI restore.
22
+ set -uo pipefail
23
+ case "${1:-}" in -h|--help) sed -n '2,21p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; esac
24
+ exec python3 "$(dirname "${BASH_SOURCE[0]}")/tf-ci-repro.py" "$@"
@@ -50,9 +50,16 @@ def cfg(key, default):
50
50
  PRUNE = {"bin", "obj", "node_modules", ".git", ".artifacts", "OldDocs", "dist", ".tfcore", ".claude", ".opencode", "packages", "TestResults"}
51
51
 
52
52
 
53
+ # The folders a project keeps its code in, in the order tf-verify-boot.sh looks (Lekhak TF-014: Lekhak's
54
+ # is source/, and --update counted only src/).
55
+ CODE_ROOTS = ("src", "source")
56
+
57
+
53
58
  def walk(root, exts, skip_samples=False):
54
59
  for d, dirs, files in os.walk(root):
55
- dirs[:] = [x for x in dirs if x not in PRUNE] # prune in place: never descend into build output
60
+ # prune in place: never descend into build output, nor into any hidden folder (.tfbuild, .vs,
61
+ # .playwright …), which holds tool output, never a page (Lekhak TF-014)
62
+ dirs[:] = [x for x in dirs if x not in PRUNE and not x.startswith(".")]
56
63
  dd = d.replace("\\", "/") + "/"
57
64
  if any(s in dd for s in SKIP):
58
65
  continue
@@ -60,20 +67,28 @@ def walk(root, exts, skip_samples=False):
60
67
  continue
61
68
  for f in files:
62
69
  if f.lower().endswith(exts):
63
- yield os.path.join(d, f).replace("\\", "/").lstrip("./")
70
+ p = os.path.join(d, f).replace("\\", "/")
71
+ # only a leading "./" goes: lstrip("./") also ate the dot of a hidden folder, and
72
+ # .tfbuild/… became tfbuild/…, which does not exist (Lekhak TF-014)
73
+ yield p[2:] if p.startswith("./") else p
74
+
75
+
76
+ def code_files(exts, skip_samples=True):
77
+ """Every file with these extensions under the project's code folders (src/, source/)."""
78
+ for r in CODE_ROOTS:
79
+ if os.path.isdir(r):
80
+ yield from walk(r, exts, skip_samples=skip_samples)
64
81
 
65
82
 
66
83
  def roles_from_usageguide(app):
67
- p = os.path.join("docs", f"{app}-UsageGuide.md")
68
- roles = []
69
- if os.path.isfile(p):
70
- m = re.search(r"(?ms)^##\s+(?:\d+[.)]\s*)?Test users[^\n]*\n(.*?)(?=^## |\Z)", read(p))
71
- if m:
72
- for line in m.group(1).splitlines():
73
- c = [x.strip() for x in line.strip().strip("|").split("|")]
74
- if len(c) >= 4 and c[0].strip("`* ").isdigit():
75
- roles.append((c[1].strip("`* "), c[3].strip("`* ")))
76
- return roles
84
+ # the guide may be {App}-UsageGuide.md or {App}-Usage-Guide.md, its heading ## or ###, and its
85
+ # table the template's numbered shape or one read by its header names (AppManager TF-009):
86
+ # the same reader tf-verify-list.py uses
87
+ import importlib.util
88
+ spec = importlib.util.spec_from_file_location("tf_verify_list", os.path.join(os.path.dirname(os.path.abspath(__file__)), "tf-verify-list.py"))
89
+ vl = importlib.util.module_from_spec(spec)
90
+ spec.loader.exec_module(vl)
91
+ return [(u["user"], u["role"]) for u in vl.test_users(vl.usage_guide(app))]
77
92
 
78
93
 
79
94
  def screens_from_uidesign(app, phase):
@@ -88,9 +103,9 @@ def screens_from_uidesign(app, phase):
88
103
 
89
104
  def routes_in_code():
90
105
  found = []
91
- for f in walk(".", (".razor", ".cshtml", ".cs", ".ts", ".tsx", ".js", ".jsx", ".vue")):
92
- if "/" not in f:
93
- continue # a top-level file is never a page
106
+ for f in walk(".", (".razor", ".cshtml", ".cs", ".ts", ".tsx", ".js", ".jsx", ".vue"), skip_samples=True):
107
+ if "/" not in f or re.search(r"\.(spec|test)\.\w+$", f):
108
+ continue # a top-level file is never a page; a test's `path:` is a screenshot, not a route (TfLens TF-050)
94
109
  txt = read(f)
95
110
  for ext_re, pat, what in ROUTE_PATTERNS:
96
111
  if re.search(ext_re, f, re.I):
@@ -141,7 +156,7 @@ def main(argv):
141
156
  kind = argv[argv.index("--kind") + 1].lower() # override for a project whose config lacks it
142
157
  sub = "app"
143
158
  if kind.startswith("lib"):
144
- razors = [f for f in walk("src", (".razor",), skip_samples=True)] if os.path.isdir("src") else []
159
+ razors = list(code_files((".razor",)))
145
160
  sub = "ui-library" if razors else "service-library"
146
161
  print(f"# tf-devguide-list — {app} — kind {sub} — phase {phase}")
147
162
  print()
@@ -184,9 +199,9 @@ def main(argv):
184
199
  for k in sorted(endpoints):
185
200
  print(f"- {k} — {', '.join(sorted(set(endpoints[k]))[:2])}")
186
201
  elif sub == "ui-library":
187
- comps = [f for f in walk("src", (".razor",), skip_samples=True)]
202
+ comps = list(code_files((".razor",)))
188
203
  if not comps:
189
- print("NOTHING: no component under src/. Build first.")
204
+ print("NOTHING: no component under src/ or source/. Build first.")
190
205
  return 0
191
206
  samples = sample_files((".razor", ".cshtml", ".html"))
192
207
  stxt = {f: read(f) for f in samples}
@@ -196,9 +211,9 @@ def main(argv):
196
211
  shown = [s for s, t in stxt.items() if re.search(rf"<{re.escape(name)}\b", t)]
197
212
  print(f"- {os.path.dirname(f).split('/')[-1]} / {name} — {f} — " + (f"shown in {shown[0]}" + (f" (+{len(shown)-1})" if len(shown) > 1 else "") if shown else "NO SAMPLE shows it: a sample gap to log"))
198
213
  else:
199
- files = [f for f in walk("src", (".cs", ".ts", ".py", ".go", ".java"), skip_samples=True)]
214
+ files = list(code_files((".cs", ".ts", ".py", ".go", ".java")))
200
215
  if not files:
201
- print("NOTHING: no source under src/. Build first.")
216
+ print("NOTHING: no source under src/ or source/. Build first.")
202
217
  return 0
203
218
  items = []
204
219
  for f in files:
@@ -222,6 +237,16 @@ def main(argv):
222
237
  print()
223
238
  if ver is None:
224
239
  print(f"## --update: no existing guide at {gp} (or no \"Verified on\"); everything is new")
240
+ # a guide kept under another name or folder (Lekhak TF-014: docs/devguides/, one file per
241
+ # role) is named, so it is read and carried over, never silently rewritten from scratch
242
+ import glob
243
+ base = os.path.basename(gp)[:-len(".md")]
244
+ others = sorted(f for f in glob.glob(os.path.join("docs", "**", base + "*.md"), recursive=True)
245
+ if os.path.normpath(f) != os.path.normpath(gp))
246
+ if others:
247
+ print(f"- {len(others)} guide file(s) under another name: " + ", ".join(f.replace(os.sep, "/") for f in others[:8])
248
+ + (" …" if len(others) > 8 else ""))
249
+ print(f" read them and carry every entry that still matches the code into {gp}; do not start from nothing")
225
250
  else:
226
251
  print(f"## --update: existing guide {gp}, Verified on {ver}, {len(entries)} entries")
227
252
  try:
@@ -230,8 +255,8 @@ def main(argv):
230
255
  except Exception:
231
256
  cutoff = None
232
257
  if cutoff:
233
- changed = [f for f in walk(".", (".razor", ".cshtml", ".cs", ".ts", ".tsx", ".js", ".py"), skip_samples=False)
234
- if f.startswith(("src/",)) and os.path.getmtime(f) > cutoff]
258
+ changed = [f for f in code_files((".razor", ".cshtml", ".cs", ".ts", ".tsx", ".js", ".py"), skip_samples=False)
259
+ if os.path.getmtime(f) > cutoff]
235
260
  print(f"- {len(changed)} source file(s) changed since then; remap the entries they serve, keep the rest verbatim")
236
261
  for f in changed[:30]:
237
262
  print(f" - {f}")
@@ -52,12 +52,20 @@ DOC_KINDS = [
52
52
  ("-devguide.md", "devguide", "app-devguide-tmpl.md"),
53
53
  ("-productguide.md", "productguide", "app-productguide-tmpl.md"),
54
54
  ("-phases.md", "phases", "app-phases-tmpl.md"),
55
+ ("-brief.md", "brief", "app-brief-tmpl.md"),
56
+ ("-decision-request.md", "decision-request", "app-decision-request-tmpl.md"),
57
+ ("-feedback.md", "feedback", "app-library-feedback-tmpl.md"),
55
58
  ]
56
59
  SIZED_DOCS = {"brd", "architecture", "uidesign", "checklist", "usageguide", "devguide", "productguide"}
57
60
  # Large projects: these four split by phase. Phase 1 keeps the plain name (App-BRD.md);
58
61
  # phase 2 onward are App-P2-BRD.md and so on. docs/TechieFlow-Document-Schemas.md §2.
59
62
  PHASED_DOCS = {"brd", "checklist", "uidesign", "devguide"}
60
63
  PHASED_SUFFIX = {"brd": "BRD", "checklist": "Checklist", "uidesign": "UIDesign", "devguide": "DevGuide"}
64
+ # A feedback entry's heading opens with its id: TF-016, TR-010, TR-RAG-002.
65
+ ENTRY_ID = re.compile(r"^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d+\b")
66
+ # A closed entry carries its resolution banner near the top of its body. The banner is not
67
+ # always first: an entry may open with a renumbering or scope note, so the window is generous.
68
+ FEEDBACK_CLOSED = re.compile(r"(?i)(✅|\bfixed upstream\b|\bwill not fix\b|\bwont-fix\b|is \*\*closed\*\*)")
61
69
  DOC_NAME = re.compile(
62
70
  r"^(.+?)(?:-P(\d+))?-(BRD|Deployment-Checklist(?:-[\w]+)?|Checklist|UIDesign|DevGuide|Architecture|Coding-Standards|UsageGuide|Usage-Guide|ProductGuide|Phases)\.md$",
63
71
  re.I)
@@ -68,6 +76,7 @@ BRD_RANGE = re.compile(r"(?:BRD-)?(\d+)\s*(?:to|-|–|—)\s*(?:BRD-)?(\d+)", re
68
76
  SIZE_NAMES = {"s": "S", "small": "S", "m": "M", "medium": "M", "l": "L", "large": "L"}
69
77
  SIZE_LONG = {"S": "Small", "M": "Medium", "L": "Large"}
70
78
  REQ_CAP = {"S": 50, "M": 100, "L": 100}
79
+ SCREEN_CAP = {"S": 10, "M": 20, "L": 20} # Large: per phase
71
80
 
72
81
  CHECKLIST_HEADER = "| ID | Requirement | Status | % | Remarks | Details |"
73
82
  STATUS_VALUES = {
@@ -293,6 +302,12 @@ def detect_kind(path: str):
293
302
  name = os.path.basename(path).lower()
294
303
  if re.search(r"-deployment-checklist(-[\w]+)?\.md$", name):
295
304
  return "deployment-checklist", "app-deployment-checklist-tmpl.md"
305
+ # phase 2 onward of a Large project: still a BRD to every cross-check, but it carries only
306
+ # its own screens and requirements and points back at phase 1 for the rest (TF-024)
307
+ if re.search(r"-p\d+-brd\.md$", name):
308
+ return "brd", "app-phase-brd-tmpl.md"
309
+ if re.search(r"-p\d+-uidesign\.md$", name):
310
+ return "uidesign", "app-phase-uidesign-tmpl.md"
296
311
  for suffix, doc, tmpl in DOC_KINDS:
297
312
  if name.endswith(suffix):
298
313
  return doc, tmpl
@@ -330,6 +345,17 @@ def split_name(path: str):
330
345
  return m.group(1), int(m.group(2) or 1)
331
346
 
332
347
 
348
+ def foreign_prefix(path: str):
349
+ """The name's app when it is not an app of its folder: docs/AppManager-api-usage-guide.md beside
350
+ docs/AppManager-BRD.md is an API reference, not a UsageGuide (AppManager TF-024). A folder
351
+ holding no BRD or checklist anchors nothing, so a lone document is checked by its name."""
352
+ app, _ = split_name(path)
353
+ folder = os.path.dirname(os.path.abspath(path))
354
+ apps = {m.group(1).lower() for m in (re.match(r"(.+?)-(BRD|Checklist)\.md$", f, re.I) for f in os.listdir(folder))
355
+ if m and not re.search(r"-P\d+$|-Deployment$", m.group(1), re.I)}
356
+ return app if app and apps and app.lower() not in apps else None
357
+
358
+
333
359
  def phase_file(app: str, phase: int, suffix: str) -> str:
334
360
  return f"{app}-{suffix}.md" if phase <= 1 else f"{app}-P{phase}-{suffix}.md"
335
361
 
@@ -378,6 +404,10 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
378
404
  if not doc:
379
405
  rep.warn(path, "not a TechieFlow document name; skipped")
380
406
  return None
407
+ foreign = foreign_prefix(path)
408
+ if foreign:
409
+ rep.warn(os.path.relpath(path, root or find_root(path)), f'"{foreign}" is not an app here (no {foreign}-BRD.md or -Checklist.md beside it); not a TechieFlow document, skipped')
410
+ return None
381
411
  try:
382
412
  schema = load_schema(tmpl)
383
413
  except (OSError, ValueError) as e:
@@ -429,6 +459,9 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
429
459
  continue
430
460
  if doc == "checklist" and re.search(r"<a id=['\"]d-req-", txt, re.I):
431
461
  continue # a page group holding detail entries
462
+ if doc == "feedback" and ENTRY_ID.match(h.strip()):
463
+ continue # an entry written at ## level: the layout every feedback file shipped
464
+ # with before this schema existed. Read as an entry, not a stranger.
432
465
  extra = "; bugs and feedback go to the misses stream, not the checklist" if doc == "checklist" else ""
433
466
  rep.fail(rel, f'section "{h}" is not in the template{extra}')
434
467
 
@@ -438,6 +471,9 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
438
471
  d = schema.declared(key)
439
472
  if not d:
440
473
  continue
474
+ if doc == "feedback" and d[1] != "required":
475
+ continue # correspondence blocks are appended over time and sit either side
476
+ # of the entries; where they land carries no meaning
441
477
  idx = order_of[norm_heading(d[0])]
442
478
  if idx < last_idx:
443
479
  rep.fail(rel, f'section "{h}" comes after "{last_name}"; the template order is the other way round')
@@ -457,14 +493,24 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
457
493
  total = word_count(body)
458
494
  if size in schema.budget:
459
495
  target, mx = schema.budget[size]
496
+ # The budget holds the size's screen cap of entries. Each entry past it brings its own
497
+ # per-entry target and maximum, which already bound it (AppManager TF-023: 54 screens).
498
+ extra, why = 0, ""
499
+ if schema.entries and schema.per_entry:
500
+ sec = section_text(present, schema.entries[0]) or ""
501
+ extra = max(0, sum(1 for h, _t in split_sections(sec, 3) if h) - SCREEN_CAP[size])
502
+ target, mx = target + extra * schema.per_entry[0], mx + extra * schema.per_entry[1]
503
+ why = f", with {extra} entries past the cap of {SCREEN_CAP[size]}" if extra else ""
460
504
  if total > mx:
461
- rep.fail(rel, f"{total:,} words; the {SIZE_LONG[size]} maximum is {mx:,} (target {target:,}). Shorten prose; never drop a screen, field or requirement to fit")
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")
462
506
  elif total > target:
463
- rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,})")
507
+ rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,}{why})")
464
508
  for key, h, txt in present:
465
509
  d = schema.declared(key)
466
510
  if d and d[2]:
467
- n = word_count(txt)
511
+ # A `####` block inside a section is detail the reader chooses to open, and is not
512
+ # counted — the same rule as a feedback entry's `#### Detail` (owner, 2026-09-08).
513
+ n = word_count(re.split(r"(?m)^####\s", txt)[0])
468
514
  if n > d[2]:
469
515
  rep.fail(rel, f'section "{h}" is {n} words; at most {d[2]}')
470
516
  if schema.max_lines:
@@ -479,8 +525,14 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
479
525
  if schema.entries:
480
526
  sec_txt = section_text(present, schema.entries[0])
481
527
  prefix = schema.entries[1]
528
+ pairs = list(split_sections(sec_txt, 3)) if sec_txt is not None else []
529
+ if doc == "feedback":
530
+ # Entries may sit under "Entries" as ###, or stand at ## level as every
531
+ # feedback file wrote them before this schema. Both are read the same way.
532
+ pairs += [(h, t) for _k, h, t in present if ENTRY_ID.match(h.strip())]
533
+ sec_txt = sec_txt if sec_txt is not None else ""
482
534
  if sec_txt is not None:
483
- for h3, etxt in split_sections(sec_txt, 3):
535
+ for h3, etxt in pairs:
484
536
  if h3 is None:
485
537
  continue
486
538
  if prefix and not h3.lower().startswith(prefix.lower()):
@@ -489,8 +541,16 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
489
541
  ename = h3[len(prefix):].strip() if prefix else h3
490
542
  ename = re.sub(r"\s*\(.*$", "", ename).strip("` ").strip()
491
543
  entry_names.append(ename)
544
+ if doc == "feedback" and FEEDBACK_CLOSED.search(etxt[:2500]):
545
+ continue # a closed entry is the record of what was wrong and why.
546
+ # History may be as long as it needs to be; the shape and the
547
+ # word cap bind the LIVE ones, which are what the owner reads.
492
548
  if schema.per_entry:
493
- n = word_count(etxt)
549
+ # The cap binds the REPORT, not the working-out. Anything under a `####`
550
+ # heading inside the entry is detail a reader chooses to open, and is not
551
+ # counted: a thorough analysis is worth having, an entry the owner cannot
552
+ # read at a glance is not (owner, 2026-09-08).
553
+ n = word_count(re.split(r"(?m)^####\s", etxt)[0])
494
554
  if n > schema.per_entry[1]:
495
555
  rep.fail(rel, f'entry "{ename}" is {n} words; maximum {schema.per_entry[1]} (target {schema.per_entry[0]})')
496
556
  elif n > schema.per_entry[0]:
@@ -549,6 +609,29 @@ def check_entry_rule(rule, ename, etxt, rel, root, path, rep):
549
609
  elif rule == "entry-expected":
550
610
  if not re.search(r"(?im)^\s*[-*]?\s*\**expected", etxt):
551
611
  rep.fail(rel, f'entry "{ename}" has no "Expected:" line')
612
+ elif rule == "entry-feedback-fields":
613
+ # A feedback entry is a bug report, not an essay. Same eight fields every time,
614
+ # so the upstream team reads them in one shape and the owner reads "Blocks" first.
615
+ for field in ("Severity", "Blocks", "Repro", "Expected", "Actual",
616
+ "Encountered in", "Workaround", "Suggested fix"):
617
+ if not re.search(r"(?im)^\s*[-*]?\s*\**%s\**\s*:" % re.escape(field), etxt):
618
+ rep.fail(rel, f'entry "{ename}" has no "{field}:" line')
619
+ elif rule == "entry-blocks-line":
620
+ # The one word that decides whether the run stops. "no" also has to say what was
621
+ # done instead, because a non-blocking entry is filed and the work carries on.
622
+ m = re.search(r"(?im)^\s*[-*]?\s*\**Blocks\**\s*:\s*(.+)$", etxt)
623
+ if m:
624
+ val = m.group(1).strip().strip("*` ").lower()
625
+ if not re.match(r"^(yes|no)\b", val):
626
+ rep.fail(rel, f'entry "{ename}" says "Blocks: {m.group(1).strip()[:40]}"; it must start "yes" or "no"')
627
+ elif val.startswith("no") and len(val) < 8:
628
+ rep.fail(rel, f'entry "{ename}" says "Blocks: no" and stops; say what was done instead and that the work carried on')
629
+ elif rule == "entry-decision-options":
630
+ if not has_table_with(etxt, "option", "what happens"):
631
+ rep.fail(rel, f'decision "{ename}" has no options table (Option, What happens, What it costs)')
632
+ elif rule == "entry-recommendation":
633
+ if not re.search(r"(?im)^\s*\**My recommendation", etxt):
634
+ rep.fail(rel, f'decision "{ename}" has no "My recommendation:" line — the owner is owed one, with the reason in a sentence')
552
635
 
553
636
 
554
637
  def check_doc_rule(rule, c, rep):
@@ -585,6 +668,22 @@ def check_doc_rule(rule, c, rep):
585
668
  elif n_acc > ACC_TARGET:
586
669
  rep.warn(rel, f"BRD-{cur} acceptance line is {n_acc} words; the target is {ACC_TARGET} (maximum {ACC_MAX})")
587
670
 
671
+ elif rule == "phase-pointer":
672
+ # a phase BRD leaves scope, users, the whole-app non-functionals, constraints and risks to
673
+ # phase 1, and a phase UIDesign the design system and the click-through flow; this section is
674
+ # how a reader finds them, so it must actually lead to the phase-1 document of the same kind
675
+ txt = section_text(present, "Where the rest lives")
676
+ if txt is None or not c.get("app"):
677
+ return
678
+ want = phase_file(c["app"], 1, PHASED_SUFFIX[c["doc"]])
679
+ hits = [l for l in re.findall(r"\]\(([^)\s]+)\)", txt)
680
+ if os.path.basename(l.split("#", 1)[0]).lower() == want.lower()]
681
+ if not hits:
682
+ rep.fail(rel, f'the "Where the rest lives" section does not link to the phase-1 document ({want}), '
683
+ f'where everything the whole application shares lives')
684
+ elif not any(resolve(root, path, l) for l in hits):
685
+ rep.fail(rel, f'the "Where the rest lives" link to {hits[0]} points at a file that does not exist')
686
+
588
687
  elif rule == "mockup-links":
589
688
  for l in sorted(links_to(clean, "mockups")):
590
689
  if not resolve(root, path, l):
@@ -609,6 +708,7 @@ def check_doc_rule(rule, c, rep):
609
708
  continue
610
709
  names.append(r[sc])
611
710
  c["brd_screens"] = names
711
+ check_named_screens(c, rep)
612
712
 
613
713
  elif rule == "stack-table":
614
714
  txt = section_text(present, "Stack decisions")
@@ -782,6 +882,70 @@ def check_doc_rule(rule, c, rep):
782
882
  if re.search(r"\b(and|or)\b|,|/", tgt):
783
883
  rep.fail(rel, f'Hosting target "{tgt}" names more than one target; one document per hosting target')
784
884
 
885
+ elif rule == "brief-must-do":
886
+ txt = section_text(present, "Must do")
887
+ if txt is None:
888
+ return
889
+ n = len(re.findall(r"(?m)^\s*\d+[.)]\s+\S", txt))
890
+ if n == 0:
891
+ rep.fail(rel, 'the "Must do" section is not a numbered list; day-1 turns each line into requirements, so one line is one thing the product does')
892
+ elif n > 25:
893
+ rep.fail(rel, f'the "Must do" section has {n} lines; a brief that long is a BRD. Keep the page, and let day-1 expand it')
894
+
895
+ elif rule == "no-glossary":
896
+ # A document written for the owner explains itself in plain words or it is
897
+ # written wrong. A section that defines the document's own vocabulary before it
898
+ # can ask its question is the tell: the fix is to change the words, never to
899
+ # teach them (owner, 2026-09-08).
900
+ for _key, h, _txt in present:
901
+ if re.search(r"(?i)\b(glossary|terminology|words this document uses|"
902
+ r"terms? (used|you)|what these words mean|definitions)\b", h):
903
+ rep.fail(rel, f'section "{h}" defines this document\'s own vocabulary; '
904
+ f'write it in plain English instead — if a word needs a glossary, use a different word')
905
+
906
+ elif rule == "feedback-blocking-count":
907
+ txt = section_text(present, "Summary")
908
+ if txt is None:
909
+ return
910
+ if not re.search(r"(?i)\bblocking\b|\bnothing is blocked\b", txt):
911
+ rep.fail(rel, 'the Summary must say how many entries are blocking right now, '
912
+ 'or "Nothing is blocked" — it is the first thing the owner reads')
913
+ # ... and its counts must be the entries' own. TfLens's read "20 entries, 8 open" over 27
914
+ # entries with 7 fixed upstream, and a TrBlazeUI file "all 28 open, none fixed upstream"
915
+ # under the library's own reply fixing 24 of them (MISS-TechieFlow-20260911-04).
916
+ sys.path.insert(0, SELF_DIR)
917
+ import tf_feedback
918
+ es = tf_feedback.entries(path)
919
+ n = {s: [e["id"] for e in es if e["state"] == s] for s in ("open", "fixed", "closed")}
920
+ truth = (f"{len(es)} entries: {len(n['open'])} open, {len(n['fixed'])} fixed upstream and not yet "
921
+ f"re-checked, {len(n['closed'])} closed")
922
+ head = re.split(r"(?m)^####\s", txt)[0]
923
+ said_total = re.search(r"(?i)\b(\d+)\s+entries\b", head)
924
+ said_open = re.search(r"(?i)\b(?:all\s+)?(\d+)\s+(?:filed and\s+)?open\b", head)
925
+ wrong = ((said_total and int(said_total.group(1)) != len(es))
926
+ or (said_open and int(said_open.group(1)) != len(n["open"])))
927
+ if wrong:
928
+ rep.fail(rel, f"the Summary's counts are not the entries' — the file holds {truth}; "
929
+ f"rewrite it from: bash .tfcore/utils/tf-feedback.sh {os.path.basename(path).split('-')[0]}")
930
+ elif n["fixed"] and not re.search(r"(?i)fixed upstream|re-?check", head):
931
+ rep.fail(rel, f"{len(n['fixed'])} entries are fixed upstream and not yet re-checked here "
932
+ f"({', '.join(n['fixed'][:5])}{' …' if len(n['fixed']) > 5 else ''}); the Summary must say so")
933
+
934
+ elif rule == "paste-back-block":
935
+ # `present` is built from the prose-only copy, which drops fenced blocks — and a
936
+ # fenced block is exactly what this rule looks for. Re-split the uncommented source.
937
+ raw_present = [(norm_heading(h), h, t) for h, t in split_sections(nocomment, 2) if h is not None]
938
+ txt = section_text(raw_present, "Copy this back to me")
939
+ if txt is None:
940
+ return
941
+ blocks = re.findall(r"^```", txt, re.M)
942
+ if len(blocks) < 2:
943
+ rep.fail(rel, 'the "Copy this back to me" section has no fenced block; '
944
+ 'the owner answers by pasting it, so it must be there to paste')
945
+ n_dec = len(c.get("entry_names") or [])
946
+ if n_dec and len(blocks) // 2 < n_dec:
947
+ rep.fail(rel, f'{n_dec} decisions but {len(blocks) // 2} paste-back block(s); one per decision')
948
+
785
949
  elif rule == "phases-table":
786
950
  txt = section_text(present, "Phases")
787
951
  if txt is None:
@@ -876,7 +1040,11 @@ def check_checklist(c, rep):
876
1040
  n = len(re.findall(r"\S+", remarks))
877
1041
  if n > 60:
878
1042
  rep.fail(rel, f"{rid} Remarks is {n} words; at most 60, current state only (history lives in the telemetry streams)")
879
- if NOT_PRESENT.search(remarks) and not NAMES_PATH.search(remarks):
1043
+ # text in backticks is quoted — a test's title or message the verdict script copied — not an agent's
1044
+ # claim about a file; an unclosed span (a Remark cut at its word cap) counts as quoted to its end
1045
+ # (Lekhak TF-012)
1046
+ said = re.sub(r"`[^`]*(?:`|$)", " ", remarks)
1047
+ if NOT_PRESENT.search(said) and not NAMES_PATH.search(remarks):
880
1048
  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")
881
1049
  m = re.search(r"\(#([^)]+)\)", details)
882
1050
  if not m:
@@ -899,8 +1067,15 @@ def check_checklist(c, rep):
899
1067
  if not m:
900
1068
  continue # reported above through the Details link
901
1069
  pos = m.start()
902
- nxt = re.search(r"\n\s*(?:[-*]\s*)?<a id=|\n## |\n### ", body[pos + 1:]) # an entry is a list item: the dash precedes the anchor (miss 21)
903
- entry = body[pos: pos + 1 + nxt.start()] if nxt else body[pos:]
1070
+ # A "### Page: …" heading written straight under the entry's own anchor belongs to the entry.
1071
+ # Stopping at the first "### " cut such an entry off before its content: three false failures
1072
+ # each, and 24 AppManager entries never really checked (TF-002). The next one still ends it.
1073
+ start = pos + 1
1074
+ own = re.match(r"<a id=[^>]*>\s*</a>[^\n]*\n(?:[ \t]*\n)*[ \t]*### [^\n]*", body[pos:])
1075
+ if own:
1076
+ start = pos + own.end()
1077
+ 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
+ entry = body[pos: start + nxt.start()] if nxt else body[pos:]
904
1079
  acc = [l for l in entry.splitlines() if ACCEPT_LINE.search(l)]
905
1080
  if len(acc) != 1:
906
1081
  rep.fail(rel, f"{rid} must have exactly one acceptance line; found {len(acc)}")
@@ -942,6 +1117,48 @@ def _screen_key(s):
942
1117
  return re.sub(r"\s+", " ", s).strip().lower()
943
1118
 
944
1119
 
1120
+ # A requirement names its screen in the template's "*Screen:* <name>" field, or in running text as
1121
+ # "**<name>** screen". Either must have a row in some phase's Screens and flow table: the row is what
1122
+ # brings the UI design entry and the mockup after it (TF-030).
1123
+ SCREEN_FIELD = re.compile(r"\*{1,2}Screen:?\*{1,2}:?\s*([^·*|\n]+)")
1124
+ SCREEN_BOLD = re.compile(r"\*\*([^*\n]{2,60})\*\*[ -]screens?\b", re.I)
1125
+ NOT_A_NAME = {"every", "any", "each", "one", "this", "that", "the", "a", "new", "same", "other", "whole", "single", "no"}
1126
+
1127
+
1128
+ def _table_screen_keys(txt):
1129
+ """Every name in the Screen column of a Screens and flow section, dialogs included."""
1130
+ out = set()
1131
+ for header, rows in tables_in(txt or ""):
1132
+ low = [h.lower() for h in header]
1133
+ if "screen" in low and "route" in low:
1134
+ sc = low.index("screen")
1135
+ out |= {_screen_key(r[sc]) for r in rows if len(r) > sc and r[sc] and not r[sc].startswith("{")}
1136
+ return out
1137
+
1138
+
1139
+ def check_named_screens(c, rep):
1140
+ known = _table_screen_keys(section_text(c["present"], "Screens and flow"))
1141
+ if c.get("app"): # a phase BRD may name a screen another phase's BRD owns
1142
+ for f in sorted(os.listdir(os.path.dirname(c["path"]))):
1143
+ if f != os.path.basename(c["path"]) and re.match(rf"^{re.escape(c['app'])}-(P\d+-)?BRD\.md$", f):
1144
+ with open(os.path.join(os.path.dirname(c["path"]), f), encoding="utf-8") as fh:
1145
+ other = [(norm_heading(h), h, t) for h, t in split_sections(strip_noise(fh.read()), 2) if h is not None]
1146
+ known |= _table_screen_keys(section_text(other, "Screens and flow"))
1147
+ cur, told = None, set()
1148
+ for line in (section_text(c["present"], "Requirements") or "").splitlines():
1149
+ m = re.search(r"\*\*BRD-(\d+)\*\*", line)
1150
+ if m:
1151
+ cur = m.group(1)
1152
+ for name in [x.group(1) for x in SCREEN_FIELD.finditer(line)] + [x.group(1) for x in SCREEN_BOLD.finditer(line)]:
1153
+ key = _screen_key(name)
1154
+ if not key or name.strip().startswith("{") or key in NOT_A_NAME or key in known or key in told:
1155
+ continue
1156
+ told.add(key)
1157
+ where = f"BRD-{cur}" if cur else "a requirement"
1158
+ rep.fail(c["rel"], f'{where} names the screen "{name.strip()}", which has no row in any Screens and flow table; '
1159
+ f'add the row, then its UI design entry and its mockup, before it is built')
1160
+
1161
+
945
1162
  def cross_checks(ctxs: dict, rep: Report, root: str):
946
1163
  """ctxs is keyed by (doc, phase). Single documents sit at phase 1."""
947
1164
  phases = sorted({p for (_d, p) in ctxs})
@@ -995,9 +1212,19 @@ def cross_phase_checks(ctxs, rep, root, phases):
995
1212
  rep.fail(cls[p]["rel"], f"{i} is also in phase {seen[i]}'s checklist; REQ ids run on across phases and are never reused")
996
1213
  seen.setdefault(i, p)
997
1214
 
1215
+ if not ph and app:
1216
+ # It may simply not have been named on the command line. Read it from disk for this rule and
1217
+ # throw its own findings away, since they belong to a run that names it: saying "the Phases
1218
+ # document does not exist" about a file that is there invites writing over it (TF-041).
1219
+ disk = os.path.join(root, "docs", f"{app}-Phases.md")
1220
+ if os.path.isfile(disk):
1221
+ try:
1222
+ ph = check_document(disk, Report(False), None, root)
1223
+ except Exception:
1224
+ ph = None
998
1225
  if not ph:
999
1226
  if len(phases) > 1 and app:
1000
- rep.fail(f"docs/{app}-Phases.md", f"phase files exist ({', '.join(f'P{p}' for p in phases if p > 1)}) but the Phases document does not; write it from app-phases-tmpl.md")
1227
+ rep.fail(f"docs/{app}-Phases.md", f"phase files exist ({', '.join(f'P{p}' for p in phases if p > 1)}) but no Phases document is on disk; write it from app-phases-tmpl.md")
1001
1228
  return
1002
1229
  rows = ph.get("phases") or []
1003
1230
  if not rows:
@@ -1148,7 +1375,7 @@ def check_mockups(root: str, rep: Report):
1148
1375
  # ----------------------------------------------------------------------------
1149
1376
  def app_files(root: str, app: str):
1150
1377
  docs = os.path.join(root, "docs")
1151
- names = [f"{app}-Phases.md", f"{app}-BRD.md", f"{app}-Architecture.md", f"{app}-UIDesign.md", f"{app}-Checklist.md",
1378
+ names = [f"{app}-Brief.md", f"{app}-Phases.md", f"{app}-BRD.md", f"{app}-Architecture.md", f"{app}-UIDesign.md", f"{app}-Checklist.md",
1152
1379
  f"{app}-Coding-Standards.md", f"{app}-UsageGuide.md", f"{app}-DevGuide.md", f"{app}-ProductGuide.md"]
1153
1380
  # later phases of a Large project: App-P2-BRD.md, App-P2-Checklist.md, … in phase order
1154
1381
  extra = []
@@ -1160,6 +1387,11 @@ def app_files(root: str, app: str):
1160
1387
  names += [f for _p, _o, f in sorted(extra)]
1161
1388
  if os.path.isdir(docs):
1162
1389
  names += sorted(f for f in os.listdir(docs) if re.match(rf"^{re.escape(app)}-Deployment-Checklist(-[\w]+)?\.md$", f))
1390
+ # the two documents written for a reader outside this project: the owner's open
1391
+ # decisions, and whatever this project has reported upstream (one file per upstream)
1392
+ names += sorted(f for f in os.listdir(docs)
1393
+ if re.match(rf"^{re.escape(app)}-Decision-Request\.md$", f)
1394
+ or re.match(rf"^{re.escape(app)}-[\w.]+-Feedback\.md$", f))
1163
1395
  out = [os.path.join(docs, n) for n in names if os.path.exists(os.path.join(docs, n))]
1164
1396
  ps = os.path.join(root, "PROJECT-STATUS.md")
1165
1397
  if os.path.exists(ps):
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bash
2
+ # tf-doc-tests.sh — run the unit tests when a test reads a document this command changed (Lekhak TF-016, 2026-09-30).
3
+ #
4
+ # bash .tfcore/utils/tf-doc-tests.sh <doc> [<doc>…] [--target <sln|csproj>]
5
+ #
6
+ # A project may guard a document with a unit test: Lekhak's VerificationRuleDocTests reads
7
+ # docs/Lekhak-UsageGuide.md and fails when the REQ-NFR-042 paragraph is gone. *amend-docs removed that
8
+ # paragraph, ran no test, closed green, and only CI noticed. This script looks for test source files
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:
11
+ # NONE no test reads <docs>; nothing to run exit 0
12
+ # PASS <n> test file(s) read <docs> (<files>); the unit tests pass — <build line> exit 0
13
+ # FAIL <n> test file(s) read <docs> (<files>); the unit tests fail — <build line> exit 1
14
+ # NOT-RUN the tests could not be run — <reason> exit 2
15
+ # A FAIL means the phase is not closed: a test guards text this command changed.
16
+ set -uo pipefail
17
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
+ source "$HERE/tf-portable.sh" # tf_read_lines, for bash 3.2 on a stock Mac
19
+ DOCS=(); TARGET=""
20
+ while [[ $# -gt 0 ]]; do
21
+ case "$1" in
22
+ --target) TARGET="${2:-}"; shift 2 ;;
23
+ -h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
24
+ *) DOCS+=("$1"); shift ;;
25
+ esac
26
+ done
27
+ [[ ${#DOCS[@]} -gt 0 ]] || { echo "tf-doc-tests: name at least one document" >&2; exit 2; }
28
+
29
+ READERS="$(python3 - "${DOCS[@]}" <<'PY'
30
+ import os, sys
31
+ names = set()
32
+ for d in sys.argv[1:]:
33
+ b = os.path.basename(d)
34
+ names.add(b)
35
+ stem = os.path.splitext(b)[0]
36
+ if len(stem) >= 6: # "README" alone would match half the code; a real document name is longer
37
+ names.add(stem)
38
+ EXT = (".cs", ".fs", ".vb", ".ts", ".tsx", ".js", ".mjs", ".py", ".java", ".kt", ".go", ".rs", ".rb", ".swift", ".dart", ".php")
39
+ SKIP = {"node_modules", "bin", "obj", ".git", ".tfcore", ".claude", ".opencode", "docs", ".artifacts"}
40
+ hits = []
41
+ for root, dirs, files in os.walk("."):
42
+ dirs[:] = [x for x in dirs if x not in SKIP and not (x.startswith(".") and x != ".")]
43
+ for fn in files:
44
+ p = os.path.join(root, fn)[2:]
45
+ if not fn.endswith(EXT) or "test" not in p.lower():
46
+ continue
47
+ try:
48
+ t = open(p, encoding="utf-8", errors="replace").read()
49
+ except OSError:
50
+ continue
51
+ if any(n in t for n in names):
52
+ hits.append(p)
53
+ print("\n".join(sorted(hits)))
54
+ PY
55
+ )"
56
+ if [[ -z "$READERS" ]]; then
57
+ echo "NONE no test reads ${DOCS[*]}; nothing to run"
58
+ exit 0
59
+ fi
60
+ N="$(wc -l <<<"$READERS" | tr -d ' ')"; LIST="$(tr '\n' ',' <<<"$READERS" | sed 's/,$//; s/,/, /g')"
61
+
62
+ # the same target choice as tf-verify-tests.sh: a root solution, else the only test project under tests/
63
+ ROOTSLN="$(compgen -G '*.sln'; compgen -G '*.slnx'; compgen -G '*.csproj')"
64
+ tf_read_lines TESTPROJ < <(find tests -name '*.csproj' -not -path 'tests/.artifacts/*' -not -path '*/bin/*' -not -path '*/obj/*' -not -path '*/node_modules/*' 2>/dev/null | sort)
65
+ [[ -z "$TARGET" && -z "$ROOTSLN" && ${#TESTPROJ[@]} -eq 1 ]] && TARGET="${TESTPROJ[0]}"
66
+ if [[ -z "$TARGET" && -z "$ROOTSLN" ]]; then
67
+ if [[ ${#TESTPROJ[@]} -gt 1 ]]; then
68
+ echo "NOT-RUN $N test file(s) read ${DOCS[*]} ($LIST), but there is no solution here and ${#TESTPROJ[@]} test projects; name one with --target"
69
+ else
70
+ echo "NOT-RUN $N test file(s) read ${DOCS[*]} ($LIST), but no .NET solution or test project is here; run the project's own unit tests and report them"
71
+ fi
72
+ exit 2
73
+ fi
74
+ LINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
75
+ case "$LINE" in
76
+ PASS*) echo "PASS $N test file(s) read ${DOCS[*]} ($LIST); the unit tests pass — $LINE"; exit 0 ;;
77
+ FAIL*) echo "FAIL $N test file(s) read ${DOCS[*]} ($LIST); the unit tests fail — $LINE"; exit 1 ;;
78
+ *) echo "NOT-RUN the unit tests could not be run — ${LINE:-tf-build.sh printed no verdict}"; exit 2 ;;
79
+ esac