@techierathore/techieflow 1.0.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 (151) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +97 -0
  2. package/.claude/commands/TechieFlow/agents/architect.md +84 -0
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +217 -0
  4. package/.claude/commands/TechieFlow/agents/verifier.md +72 -0
  5. package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +138 -0
  6. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +98 -0
  7. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +78 -0
  8. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +77 -0
  9. package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +117 -0
  10. package/.claude/commands/TechieFlow/tasks/amend-docs.md +149 -0
  11. package/.claude/commands/TechieFlow/tasks/author-brd.md +79 -0
  12. package/.claude/commands/TechieFlow/tasks/build-phase.md +231 -0
  13. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +278 -0
  14. package/.claude/commands/TechieFlow/tasks/create-doc.md +101 -0
  15. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +507 -0
  16. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +224 -0
  17. package/.claude/commands/TechieFlow/tasks/devguide.md +224 -0
  18. package/.claude/commands/TechieFlow/tasks/document-project.md +343 -0
  19. package/.claude/commands/TechieFlow/tasks/execute-checklist.md +86 -0
  20. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +136 -0
  21. package/.claude/commands/TechieFlow/tasks/fix-issues.md +134 -0
  22. package/.claude/commands/TechieFlow/tasks/generate-html.md +115 -0
  23. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +126 -0
  24. package/.claude/commands/TechieFlow/tasks/index-docs.md +173 -0
  25. package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +75 -0
  26. package/.claude/commands/TechieFlow/tasks/log-miss.md +160 -0
  27. package/.claude/commands/TechieFlow/tasks/metrics-report.md +143 -0
  28. package/.claude/commands/TechieFlow/tasks/mockups.md +122 -0
  29. package/.claude/commands/TechieFlow/tasks/productguide.md +105 -0
  30. package/.claude/commands/TechieFlow/tasks/refresh-status.md +135 -0
  31. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +117 -0
  32. package/.claude/commands/TechieFlow/tasks/shard-doc.md +185 -0
  33. package/.claude/commands/TechieFlow/tasks/split-brd.md +131 -0
  34. package/.claude/commands/TechieFlow/tasks/triage-issues.md +157 -0
  35. package/.claude/commands/TechieFlow/tasks/verify-phase.md +579 -0
  36. package/.claude/commands/generate-html.md +124 -0
  37. package/.codex/config.toml +46 -0
  38. package/.codex/hooks.json +63 -0
  39. package/.codex/rules/techieflow.rules +8 -0
  40. package/.opencode/command/generate-html.md +129 -0
  41. package/.opencode/plugin/techieflow.js +350 -0
  42. package/.tfcore/TOKEN-GUIDE.md +59 -0
  43. package/.tfcore/agents/analyst.md +97 -0
  44. package/.tfcore/agents/architect.md +84 -0
  45. package/.tfcore/agents/flow-master.md +217 -0
  46. package/.tfcore/agents/verifier.md +72 -0
  47. package/.tfcore/checklists/architect-checklist.md +438 -0
  48. package/.tfcore/core-config.yaml +47 -0
  49. package/.tfcore/data/brainstorming-techniques.md +36 -0
  50. package/.tfcore/data/elicitation-methods.md +154 -0
  51. package/.tfcore/data/techieflow-kb.md +807 -0
  52. package/.tfcore/data/technical-preferences.md +3 -0
  53. package/.tfcore/enhanced-ide-development-workflow.md +248 -0
  54. package/.tfcore/hooks/block-git.sh +301 -0
  55. package/.tfcore/hooks/codex-adapter.py +248 -0
  56. package/.tfcore/hooks/guard-artifacts.sh +101 -0
  57. package/.tfcore/hooks/guard-status-html.sh +102 -0
  58. package/.tfcore/hooks/guard-status.sh +124 -0
  59. package/.tfcore/hooks/guard-verify.sh +108 -0
  60. package/.tfcore/hooks/metrics-session.sh +129 -0
  61. package/.tfcore/hooks/session-pointer.sh +52 -0
  62. package/.tfcore/hooks/sweep-artifacts.sh +198 -0
  63. package/.tfcore/install-manifest.yaml +231 -0
  64. package/.tfcore/routing.yaml +105 -0
  65. package/.tfcore/tasks/_metrics-emit-gate.md +138 -0
  66. package/.tfcore/tasks/_smoke-test-policy.md +98 -0
  67. package/.tfcore/tasks/_status-update-gate.md +78 -0
  68. package/.tfcore/tasks/_yolo-mode.md +77 -0
  69. package/.tfcore/tasks/advanced-elicitation.md +117 -0
  70. package/.tfcore/tasks/amend-docs.md +149 -0
  71. package/.tfcore/tasks/author-brd.md +79 -0
  72. package/.tfcore/tasks/build-phase.md +231 -0
  73. package/.tfcore/tasks/create-deep-research-prompt.md +278 -0
  74. package/.tfcore/tasks/create-doc.md +101 -0
  75. package/.tfcore/tasks/day1-brownfield.md +507 -0
  76. package/.tfcore/tasks/day1-greenfield.md +224 -0
  77. package/.tfcore/tasks/devguide.md +224 -0
  78. package/.tfcore/tasks/document-project.md +343 -0
  79. package/.tfcore/tasks/execute-checklist.md +86 -0
  80. package/.tfcore/tasks/facilitate-brainstorming-session.md +136 -0
  81. package/.tfcore/tasks/fix-issues.md +134 -0
  82. package/.tfcore/tasks/generate-html.md +115 -0
  83. package/.tfcore/tasks/handoff-phase.md +126 -0
  84. package/.tfcore/tasks/index-docs.md +173 -0
  85. package/.tfcore/tasks/kb-mode-interaction.md +75 -0
  86. package/.tfcore/tasks/log-miss.md +160 -0
  87. package/.tfcore/tasks/metrics-report.md +143 -0
  88. package/.tfcore/tasks/mockups.md +122 -0
  89. package/.tfcore/tasks/productguide.md +105 -0
  90. package/.tfcore/tasks/refresh-status.md +135 -0
  91. package/.tfcore/tasks/render-workflow-docs.md +117 -0
  92. package/.tfcore/tasks/shard-doc.md +185 -0
  93. package/.tfcore/tasks/split-brd.md +131 -0
  94. package/.tfcore/tasks/triage-issues.md +157 -0
  95. package/.tfcore/tasks/verify-phase.md +579 -0
  96. package/.tfcore/telemetry/SCHEMA.md +678 -0
  97. package/.tfcore/telemetry/install-metrics.sh +390 -0
  98. package/.tfcore/telemetry/pre-commit +94 -0
  99. package/.tfcore/telemetry/tf-metrics.sh +1415 -0
  100. package/.tfcore/templates/architecture-tmpl.yaml +650 -0
  101. package/.tfcore/templates/brainstorming-output-tmpl.yaml +156 -0
  102. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +476 -0
  103. package/.tfcore/templates/competitor-analysis-tmpl.yaml +306 -0
  104. package/.tfcore/templates/front-end-architecture-tmpl.yaml +218 -0
  105. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +823 -0
  106. package/.tfcore/templates/market-research-tmpl.yaml +252 -0
  107. package/.tfcore/templates/project-brief-tmpl.yaml +221 -0
  108. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +61 -0
  109. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +127 -0
  110. package/.tfcore/templates/v4custom/app-brd-tmpl.md +204 -0
  111. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +90 -0
  112. package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +33 -0
  113. package/.tfcore/templates/v4custom/app-devguide-tmpl.md +167 -0
  114. package/.tfcore/templates/v4custom/app-editorconfig-tmpl.editorconfig +44 -0
  115. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +31 -0
  116. package/.tfcore/templates/v4custom/app-productguide-tmpl.md +54 -0
  117. package/.tfcore/templates/v4custom/app-project-status-tmpl.md +98 -0
  118. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +48 -0
  119. package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +67 -0
  120. package/.tfcore/templates/v4custom/build-invocation-ladder.md +190 -0
  121. package/.tfcore/templates/v4custom/html-render-shell.md +502 -0
  122. package/.tfcore/templates/v4custom/metrics-report-template.md +273 -0
  123. package/.tfcore/templates/v4custom/opencode-operating-contract.md +56 -0
  124. package/.tfcore/user-guide.md +633 -0
  125. package/.tfcore/utils/techieflow-doc-template.md +325 -0
  126. package/.tfcore/utils/tf-assets.sh +310 -0
  127. package/.tfcore/utils/tf-codex-bind.py +196 -0
  128. package/.tfcore/utils/tf-codex-telemetry.py +60 -0
  129. package/.tfcore/utils/tf-emit.sh +1075 -0
  130. package/.tfcore/utils/tf-gitignore-audit.sh +281 -0
  131. package/.tfcore/utils/tf-goal.sh +421 -0
  132. package/.tfcore/utils/tf-harness.sh +160 -0
  133. package/.tfcore/utils/tf-mockup-parity.mjs +555 -0
  134. package/.tfcore/utils/tf-mockup-parity.sh +65 -0
  135. package/.tfcore/utils/tf-opencode-audit.py +126 -0
  136. package/.tfcore/utils/tf-perf.sh +241 -0
  137. package/.tfcore/utils/tf-render-html.py +715 -0
  138. package/.tfcore/utils/tf-render-html.sh +28 -0
  139. package/.tfcore/utils/tf-routing-bind.sh +216 -0
  140. package/.tfcore/utils/tf-routing.sh +247 -0
  141. package/.tfcore/utils/tf-yolo.sh +119 -0
  142. package/.tfcore/working-in-the-brownfield.md +606 -0
  143. package/LICENSE +201 -0
  144. package/README.md +1212 -0
  145. package/WORKFLOW.html +1986 -0
  146. package/docs/TechieFlow-Installation.md +299 -0
  147. package/opencode.jsonc +929 -0
  148. package/package.json +60 -0
  149. package/scripts/install.mjs +992 -0
  150. package/scripts/test-install.mjs +505 -0
  151. package/scripts/validate.mjs +155 -0
@@ -0,0 +1,715 @@
1
+ #!/usr/bin/env python3
2
+ """tf-render-html — render framework Markdown docs to the shared HTML shell.
3
+
4
+ Implements .tfcore/templates/v4custom/html-render-shell.md. That file stays the
5
+ SPECIFICATION and the single source of truth: the CSS (§2), the flash-free theme
6
+ script (§3) and the JS (§7) are EXTRACTED FROM IT at render time, never copied in
7
+ here, so the shell cannot drift from its own documentation.
8
+
9
+ Usage:
10
+ python3 tf-render-html.py <file.md> [more.md ...] [--out DIR] [--quiet]
11
+
12
+ Writes a sibling <file>.html for each input. Exit codes:
13
+ 0 all rendered
14
+ 1 a render failed
15
+ 2 refused (checklist, or missing spec)
16
+
17
+ Dependency-free by design: Python 3 standard library only. The reference machine
18
+ has no pandoc / python-markdown / node markdown library installed.
19
+ """
20
+
21
+ import datetime
22
+ import html as _html
23
+ import os
24
+ import re
25
+ import sys
26
+
27
+ SPEC_REL = os.path.join("templates", "v4custom", "html-render-shell.md")
28
+
29
+
30
+ # ----------------------------------------------------------------------------
31
+ # Spec extraction — §2 CSS, §3 head theme script, §7 JS
32
+ # ----------------------------------------------------------------------------
33
+
34
+ def find_spec(explicit=None):
35
+ """Locate html-render-shell.md. Framework files live under a hidden,
36
+ gitignored .tfcore/ — never search for them, resolve the known path."""
37
+ if explicit:
38
+ return explicit if os.path.isfile(explicit) else None
39
+ here = os.path.dirname(os.path.abspath(__file__)) # .tfcore/utils
40
+ cand = [os.path.join(os.path.dirname(here), SPEC_REL)] # .tfcore/...
41
+ cwd = os.getcwd()
42
+ cand.append(os.path.join(cwd, ".tfcore", SPEC_REL))
43
+ for c in cand:
44
+ if os.path.isfile(c):
45
+ return c
46
+ return None
47
+
48
+
49
+ def _fence(spec_text, lang, after=None):
50
+ """Return the body of the first ```<lang> fence, optionally after a marker."""
51
+ text = spec_text
52
+ if after:
53
+ i = text.find(after)
54
+ if i < 0:
55
+ return None
56
+ text = text[i:]
57
+ m = re.search(r"^```" + lang + r"\s*\n(.*?)^```", text, re.S | re.M)
58
+ return m.group(1) if m else None
59
+
60
+
61
+ def load_shell(spec_path):
62
+ spec = open(spec_path, encoding="utf-8").read()
63
+ css = _fence(spec, "css", after="## 2. CSS")
64
+ js = _fence(spec, "javascript", after="## 7. JavaScript")
65
+ skeleton = _fence(spec, "html", after="## 3. Page skeleton")
66
+ head_js = None
67
+ if skeleton:
68
+ m = re.search(r"<script>\s*(/\* Resolve the theme.*?)</script>",
69
+ skeleton, re.S)
70
+ if m:
71
+ head_js = m.group(1)
72
+ missing = [n for n, v in (("§2 CSS", css), ("§7 JS", js),
73
+ ("§3 theme script", head_js)) if not v]
74
+ if missing:
75
+ raise SystemExit("tf-render-html: cannot extract %s from %s"
76
+ % (", ".join(missing), spec_path))
77
+ return css.rstrip(), head_js.strip(), js.rstrip()
78
+
79
+
80
+ # ----------------------------------------------------------------------------
81
+ # §1 slug rule
82
+ # ----------------------------------------------------------------------------
83
+
84
+ class Slugger(object):
85
+ def __init__(self):
86
+ self.seen = {}
87
+ self.n = 0
88
+
89
+ def slug(self, text):
90
+ self.n += 1
91
+ s = re.sub(r"<[^>]+>", "", text) # strip any inline html
92
+ s = re.sub(r"[*_`~]", "", s) # strip md emphasis
93
+ s = s.lower()
94
+ # 2. leading numbering / § / dashes, only before the first letter
95
+ s = re.sub(r"^[\s\d.§#—–\-]+", "", s)
96
+ s = re.sub(r"[^a-z0-9]+", "-", s) # 3.
97
+ s = s.strip("-") # 4.
98
+ if not s: # 5.
99
+ s = "section-%d" % self.n
100
+ c = self.seen.get(s, 0) + 1 # 6.
101
+ self.seen[s] = c
102
+ return s if c == 1 else "%s-%d" % (s, c)
103
+
104
+
105
+ # ----------------------------------------------------------------------------
106
+ # Inline conversion
107
+ # ----------------------------------------------------------------------------
108
+
109
+ RAW_TAG = re.compile(
110
+ r"</?(?:a|abbr|b|br|code|em|i|img|kbd|mark|s|small|span|strong|sub|sup|u)\b[^>]*>",
111
+ re.I)
112
+
113
+
114
+ def inline(text):
115
+ """Markdown inline -> HTML. Code spans and deliberate raw inline HTML are
116
+ protected before escaping, so `<a id="d-req-ui-001"></a>` survives verbatim
117
+ (html-render-shell §4) while stray < and & are still escaped."""
118
+ slots = []
119
+
120
+ def stash(s):
121
+ slots.append(s)
122
+ return "\x00%d\x00" % (len(slots) - 1)
123
+
124
+ # code spans first — their content must never be md-parsed
125
+ def code_span(m):
126
+ return stash("<code>%s</code>" % _html.escape(m.group(2), quote=False))
127
+ text = re.sub(r"(`+)(.+?)\1", code_span, text, flags=re.S)
128
+
129
+ # deliberate raw inline HTML (anchors above all)
130
+ text = RAW_TAG.sub(lambda m: stash(m.group(0)), text)
131
+
132
+ text = _html.escape(text, quote=False)
133
+
134
+ # images before links
135
+ text = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)",
136
+ lambda m: '<img src="%s" alt="%s" style="max-width:100%%">'
137
+ % (m.group(2), m.group(1)), text)
138
+ text = re.sub(r"\[([^\]]+)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)",
139
+ lambda m: '<a href="%s">%s</a>' % (m.group(2), m.group(1)), text)
140
+ text = re.sub(r"<(https?://[^>\s]+)>", r'<a href="\1">\1</a>', text)
141
+
142
+ text = re.sub(r"\*\*\*(.+?)\*\*\*", r"<strong><em>\1</em></strong>", text, flags=re.S)
143
+ text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text, flags=re.S)
144
+ text = re.sub(r"~~(.+?)~~", r"<s>\1</s>", text, flags=re.S)
145
+ text = re.sub(r"(?<![\w*])\*(?!\s)([^*]+?)(?<!\s)\*(?![\w*])", r"<em>\1</em>", text)
146
+ text = re.sub(r"(?<![\w_])_(?!\s)([^_]+?)(?<!\s)_(?![\w_])", r"<em>\1</em>", text)
147
+
148
+ for i, s in enumerate(slots):
149
+ text = text.replace("\x00%d\x00" % i, s)
150
+ return text
151
+
152
+
153
+ # ----------------------------------------------------------------------------
154
+ # Block parsing
155
+ # ----------------------------------------------------------------------------
156
+
157
+ # §6b — visible blockquotes addressed to the DRAFTING AGENT, never to the reader.
158
+ AGENT_NOTE = re.compile(
159
+ r"(depth mandate|mermaid mandate|read (this )?before drafting|"
160
+ r"how to use this template|template instructions?:|"
161
+ r"ai agent:|agent note:|note to the agent)", re.I)
162
+
163
+ FENCE = re.compile(r"^(\s*)(`{3,}|~{3,})\s*([\w+-]*)\s*$")
164
+ HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
165
+ HR = re.compile(r"^\s*(?:-{3,}|\*{3,}|_{3,})\s*$")
166
+ LI = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+(.*)$")
167
+ TABLE_SEP = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
168
+ HTML_BLOCK = re.compile(r"^\s*<(/?)([a-zA-Z][\w-]*)")
169
+
170
+
171
+ class Renderer(object):
172
+ def __init__(self, slugger):
173
+ self.sl = slugger
174
+ self.out = []
175
+ self.toc = [] # (level, slug, text)
176
+ self.h2 = 0
177
+ self.stripped_notes = 0
178
+ self.diagrams = 0
179
+ self.bad_labels = []
180
+
181
+ # -- helpers ---------------------------------------------------------
182
+ def emit(self, s):
183
+ self.out.append(s)
184
+
185
+ def heading(self, level, text):
186
+ slug = self.sl.slug(text)
187
+ if level in (2, 3, 4):
188
+ self.emit('<h%d id="%s">%s<a class="anchor-link" href="#%s">#</a></h%d>'
189
+ % (level, slug, inline(text), slug, level))
190
+ else:
191
+ self.emit("<h%d>%s</h%d>" % (level, inline(text), level))
192
+ if level == 2:
193
+ self.h2 += 1
194
+ if level in (2, 3):
195
+ self.toc.append((level, slug, re.sub(r"<[^>]+>", "", inline(text))))
196
+
197
+ def mermaid(self, src):
198
+ self.diagrams += 1
199
+ self.check_mermaid(src)
200
+ # Concatenation, not %-formatting: this block legitimately contains
201
+ # "100%" and a % literal makes %-formatting choke on the next char.
202
+ self.emit('<div class="diagram">\n'
203
+ ' <div class="dt">\n'
204
+ ' <button data-zout title="Zoom out">&minus;</button>\n'
205
+ ' <span class="zlabel">100%</span>\n'
206
+ ' <button data-zin title="Zoom in">+</button>\n'
207
+ ' <button data-fit title="Fit to width">Fit</button>\n'
208
+ ' <button data-one title="Actual size">1:1</button>\n'
209
+ ' <button data-fs title="Fullscreen">&#9166;</button>\n'
210
+ ' <button data-pop title="Open in new tab">&#8599;</button>\n'
211
+ ' </div>\n'
212
+ ' <pre class="mermaid">' + src.rstrip() + '</pre>\n'
213
+ '</div>')
214
+
215
+ def check_mermaid(self, src):
216
+ """§5.5 self-check: report unquoted labels containing special chars, and
217
+ a lowercase `end` node id. Reported, never silently rewritten — the
218
+ renderer does not get to guess what the author meant."""
219
+ # Scope: flowcharts ONLY. §5.5 says sequence diagrams rarely break —
220
+ # their message text is free-form, so `A->>B: SignIn(cookie: id, email)`
221
+ # is legal and scanning it reports pure false positives.
222
+ first = next((l.strip() for l in src.split("\n") if l.strip()), "")
223
+ if not re.match(r"^(flowchart|graph)\b", first):
224
+ return
225
+ for line in src.split("\n"):
226
+ # A quoted label is safe BY DEFINITION (§5.5 rule 1) and may legally
227
+ # contain (), /, & and the rest. Drop every quoted span first, or the
228
+ # scan reports the inside of correctly-quoted labels as broken.
229
+ bare = re.sub(r'"[^"]*"', '""', line)
230
+ # Each shape's label runs to its OWN closing bracket, so an inner
231
+ # "(v2)" stays part of the label instead of matching on its own.
232
+ cands = [m for m in re.finditer(r"\[([^\]]*)\]|\{([^}]*)\}|\(([^)]*)\)", bare)]
233
+ cands += list(re.finditer(r"\|([^|]+)\|", bare)) # rule 6 edge labels
234
+ for m in cands:
235
+ lab = next((g for g in m.groups() if g is not None), "")
236
+ core = re.sub(r'^[\[\({"\s]+|[\]\)}"\s]+$', "", lab.strip())
237
+ if not core or not re.search(r"[A-Za-z0-9]", core):
238
+ continue
239
+ if re.search(r"[()/\\&:,;#@<>|`%]", core):
240
+ self.bad_labels.append(core[:60])
241
+ for m in re.finditer(r"(?m)^\s*end\s*(\[|\(|\{|-->)", src):
242
+ self.bad_labels.append("reserved node id `end`")
243
+
244
+ def code(self, lang, src):
245
+ cls = ' class="language-%s"' % lang if lang else ""
246
+ self.emit("<pre><code%s>%s</code></pre>"
247
+ % (cls, _html.escape(src.rstrip("\n"), quote=False)))
248
+
249
+ def table(self, header, rows, aligns):
250
+ def cell(tag, text, i):
251
+ a = aligns[i] if i < len(aligns) else ""
252
+ st = ' style="text-align:%s"' % a if a else ""
253
+ return "<%s%s>%s</%s>" % (tag, st, inline(text), tag)
254
+ h = "".join(cell("th", c, i) for i, c in enumerate(header))
255
+ body = []
256
+ for r in rows:
257
+ body.append("<tr>%s</tr>"
258
+ % "".join(cell("td", c, i) for i, c in enumerate(r)))
259
+ self.emit("<table>\n<thead><tr>%s</tr></thead>\n<tbody>\n%s\n</tbody>\n</table>"
260
+ % (h, "\n".join(body)))
261
+
262
+
263
+ def split_row(line):
264
+ line = line.strip()
265
+ if line.startswith("|"):
266
+ line = line[1:]
267
+ if line.endswith("|"):
268
+ line = line[:-1]
269
+ parts, buf, esc = [], "", False
270
+ for ch in line:
271
+ if esc:
272
+ buf += ch
273
+ esc = False
274
+ elif ch == "\\":
275
+ esc = True
276
+ buf += "\\"
277
+ elif ch == "|":
278
+ parts.append(buf.strip())
279
+ buf = ""
280
+ else:
281
+ buf += ch
282
+ parts.append(buf.strip())
283
+ return [p.replace("\\|", "|") for p in parts]
284
+
285
+
286
+ def parse(lines, r):
287
+ i, n = 0, len(lines)
288
+ para = []
289
+
290
+ def flush_para():
291
+ if para:
292
+ r.emit("<p>%s</p>" % inline("\n".join(para).strip()))
293
+ del para[:]
294
+
295
+ while i < n:
296
+ line = lines[i]
297
+
298
+ # fenced block
299
+ m = FENCE.match(line)
300
+ if m:
301
+ flush_para()
302
+ marker, lang = m.group(2), (m.group(3) or "").lower()
303
+ i += 1
304
+ buf = []
305
+ while i < n and not (lines[i].strip().startswith(marker[0] * len(marker))
306
+ and lines[i].strip().rstrip(marker[0]) == ""):
307
+ buf.append(lines[i])
308
+ i += 1
309
+ i += 1
310
+ src = "\n".join(buf)
311
+ if lang == "mermaid":
312
+ r.mermaid(_html.escape(src, quote=False))
313
+ else:
314
+ r.code(lang, src)
315
+ continue
316
+
317
+ # html comment — invisible by design (§6b)
318
+ if line.lstrip().startswith("<!--"):
319
+ flush_para()
320
+ while i < n and "-->" not in lines[i]:
321
+ i += 1
322
+ i += 1
323
+ continue
324
+
325
+ m = HEADING.match(line)
326
+ if m:
327
+ flush_para()
328
+ r.heading(len(m.group(1)), m.group(2))
329
+ i += 1
330
+ continue
331
+
332
+ if HR.match(line) and not para:
333
+ flush_para()
334
+ r.emit("<hr>")
335
+ i += 1
336
+ continue
337
+
338
+ # table
339
+ if "|" in line and i + 1 < n and TABLE_SEP.match(lines[i + 1]):
340
+ flush_para()
341
+ header = split_row(line)
342
+ aligns = []
343
+ for spec in split_row(lines[i + 1]):
344
+ s = spec.strip()
345
+ aligns.append("center" if s.startswith(":") and s.endswith(":")
346
+ else "right" if s.endswith(":")
347
+ else "left" if s.startswith(":") else "")
348
+ i += 2
349
+ rows = []
350
+ while i < n and "|" in lines[i] and lines[i].strip():
351
+ rows.append(split_row(lines[i]))
352
+ i += 1
353
+ r.table(header, rows, aligns)
354
+ continue
355
+
356
+ # blockquote
357
+ if line.lstrip().startswith(">"):
358
+ flush_para()
359
+ buf = []
360
+ while i < n and (lines[i].lstrip().startswith(">") or
361
+ (buf and lines[i].strip() and not FENCE.match(lines[i])
362
+ and not HEADING.match(lines[i]))):
363
+ buf.append(re.sub(r"^\s*>\s?", "", lines[i]))
364
+ i += 1
365
+ body = "\n".join(buf).strip()
366
+ if AGENT_NOTE.search(body.split("\n")[0]) or AGENT_NOTE.search(body[:160]):
367
+ r.stripped_notes += 1 # §6b — never shown to a reader
368
+ continue
369
+ sub = Renderer(r.sl)
370
+ sub.h2 = -10 ** 6 # quotes never contribute to TOC
371
+ parse(body.split("\n"), sub)
372
+ r.diagrams += sub.diagrams
373
+ r.bad_labels += sub.bad_labels
374
+ r.emit("<blockquote>\n%s\n</blockquote>" % "\n".join(sub.out))
375
+ continue
376
+
377
+ # list
378
+ if LI.match(line):
379
+ flush_para()
380
+ i = parse_list(lines, i, r)
381
+ continue
382
+
383
+ # raw html block (deliberate anchors, divs, details…)
384
+ if HTML_BLOCK.match(line) and not para:
385
+ flush_para()
386
+ r.emit(line)
387
+ i += 1
388
+ continue
389
+
390
+ if not line.strip():
391
+ flush_para()
392
+ i += 1
393
+ continue
394
+
395
+ para.append(line)
396
+ i += 1
397
+
398
+ flush_para()
399
+
400
+
401
+ def parse_list(lines, i, r):
402
+ n = len(lines)
403
+ m = LI.match(lines[i])
404
+ base = len(m.group(1))
405
+ ordered = not m.group(2) in ("-", "*", "+")
406
+ tag = "ol" if ordered else "ul"
407
+ r.emit("<%s>" % tag)
408
+ while i < n:
409
+ m = LI.match(lines[i])
410
+ if not m:
411
+ if not lines[i].strip():
412
+ # blank line: continue only if the next line is still this list
413
+ j = i + 1
414
+ while j < n and not lines[j].strip():
415
+ j += 1
416
+ if j < n and LI.match(lines[j]) and len(LI.match(lines[j]).group(1)) >= base:
417
+ i = j
418
+ continue
419
+ break
420
+ indent = len(m.group(1))
421
+ if indent < base:
422
+ break
423
+ if indent > base:
424
+ i = parse_list(lines, i, r)
425
+ continue
426
+ content = [m.group(3)]
427
+ i += 1
428
+ # lazy continuation lines belonging to this item
429
+ while i < n and lines[i].strip() and not LI.match(lines[i]) \
430
+ and not HEADING.match(lines[i]) and not FENCE.match(lines[i]) \
431
+ and len(lines[i]) - len(lines[i].lstrip()) > base:
432
+ content.append(lines[i].strip())
433
+ i += 1
434
+ r.emit("<li>%s" % inline(" ".join(content)))
435
+ if i < n:
436
+ m2 = LI.match(lines[i])
437
+ if m2 and len(m2.group(1)) > base:
438
+ i = parse_list(lines, i, r)
439
+ r.emit("</li>")
440
+ r.emit("</%s>" % tag)
441
+ return i
442
+
443
+
444
+ # ----------------------------------------------------------------------------
445
+ # Document assembly
446
+ # ----------------------------------------------------------------------------
447
+
448
+ def frontmatter(lines):
449
+ if not lines or lines[0].strip() != "---":
450
+ return {}, lines
451
+ for j in range(1, len(lines)):
452
+ if lines[j].strip() == "---":
453
+ fm = {}
454
+ for ln in lines[1:j]:
455
+ if ":" in ln:
456
+ k, v = ln.split(":", 1)
457
+ fm[k.strip()] = v.strip().strip('"\'')
458
+ return fm, lines[j + 1:]
459
+ return {}, lines
460
+
461
+
462
+ def is_requirements_checklist(base, raw):
463
+ """Is this THE agent working document, or just a file with 'Checklist' in its name?
464
+
465
+ The §0 ban is about `docs/{AppName}-Checklist.md` — the per-REQ Requirements
466
+ Status table agents read in markdown. A deployment runbook, a release checklist
467
+ or a QA checklist is an ordinary human document that happens to share the word,
468
+ and refusing it pushes an agent back toward hand-authoring the HTML, which is
469
+ the exact path tf-render-html exists to remove (TfLens feedback TF-004).
470
+
471
+ So identify the document, do not guess from the suffix: the name must match AND
472
+ the content must carry the checklist's own structure — the `## Requirements
473
+ Status` heading, or the template's SINGLE SOURCE OF TRUTH marker comment. Both
474
+ ship in `app-checklist-tmpl.md`; a runbook has neither."""
475
+ if not re.search(r"-Checklist\.md$", base, re.I):
476
+ return False
477
+ head = raw[:8000]
478
+ return bool(re.search(r"^##\s+Requirements Status\s*$", head, re.M)
479
+ or "SINGLE SOURCE OF TRUTH" in head)
480
+
481
+
482
+ NEXT_CMD_H2 = re.compile(r"^##\s+Next command to run\s*$", re.I)
483
+
484
+
485
+ def next_command_cta(base, lines):
486
+ """The PROJECT-STATUS call-to-action box (render-workflow-docs §5).
487
+
488
+ `render-workflow-docs.md` §5 has always required a "NEXT COMMAND TO RUN" box at
489
+ the very top of <main>, and its Output Checklist repeats the requirement — but
490
+ when hand-authoring moved into this script under TF-003, the box did not come
491
+ with it. The fingerprint was left in plain sight: html-render-shell §2 defines
492
+ `--cta-bg` in both palettes and nothing consumed it. A spec that says "always add
493
+ this" beside a renderer that never does is worse than either alone, because it
494
+ makes the checklist item unfalsifiable by reading the task (TfLens TF-010).
495
+
496
+ So it is emitted here, mechanically, from the source the spec already names: the
497
+ first fenced code block under `## Next command to run`. Same shape as the
498
+ frontmatter special case above it.
499
+
500
+ Returns "" for every other document, and for a PROJECT-STATUS whose Next-command
501
+ section is missing or empty — an absent box is honest; an invented command is not.
502
+ """
503
+ if base.lower() != "project-status.md":
504
+ return ""
505
+ cmd = None
506
+ for i, ln in enumerate(lines):
507
+ if not NEXT_CMD_H2.match(ln):
508
+ continue
509
+ for j in range(i + 1, len(lines)):
510
+ s = lines[j].strip()
511
+ if s.startswith("## "): # next section — nothing to show
512
+ break
513
+ if s.startswith("```"):
514
+ body = []
515
+ for k in range(j + 1, len(lines)):
516
+ if lines[k].strip().startswith("```"):
517
+ break
518
+ body.append(lines[k].rstrip())
519
+ body = [b for b in body if b.strip()]
520
+ if body:
521
+ cmd = body[0].strip()
522
+ break
523
+ break
524
+ if not cmd:
525
+ return ""
526
+ # Theme variables only — never a hardcoded hex, so the box stays legible in both
527
+ # palettes (§5's own instruction, and the reason --cta-bg exists).
528
+ return (' <div class="cta-next" style="padding:20px; background:var(--cta-bg); '
529
+ 'border:2px solid var(--accent); border-radius:10px; margin:20px 0;">\n'
530
+ ' <div style="font-size:11px; color:var(--muted); letter-spacing:.5px;">'
531
+ 'NEXT COMMAND TO RUN</div>\n'
532
+ ' <div style="font-family:var(--mono); font-size:18px; margin-top:6px;">'
533
+ '%s</div>\n'
534
+ ' </div>\n' % _html.escape(cmd))
535
+
536
+
537
+ def render(md_path, css, head_js, body_js, out_dir=None):
538
+ base = os.path.basename(md_path)
539
+ raw_probe = open(md_path, encoding="utf-8").read().replace("\r\n", "\n")
540
+ if is_requirements_checklist(base, raw_probe):
541
+ raise Refused("%s is the requirements checklist — checklists are AI-agent "
542
+ "working documents and are NEVER rendered to HTML "
543
+ "(html-render-shell §0)." % base)
544
+
545
+ raw = raw_probe
546
+ lines = raw.split("\n")
547
+ fm, lines = frontmatter(lines)
548
+
549
+ title = None
550
+ for k, ln in enumerate(lines):
551
+ m = HEADING.match(ln)
552
+ if m and len(m.group(1)) == 1:
553
+ title = m.group(2).strip()
554
+ lines = lines[:k] + lines[k + 1:]
555
+ break
556
+ if not title:
557
+ title = re.sub(r"\.md$", "", base)
558
+
559
+ # Read the CTA source BEFORE parse() consumes `lines` (§5, TfLens TF-010).
560
+ cta = next_command_cta(base, lines)
561
+
562
+ r = Renderer(Slugger())
563
+ parse(lines, r)
564
+
565
+ today = datetime.date.today().isoformat()
566
+ subtitle = "Rendered %s &middot; source <code>%s</code>" % (today, base)
567
+
568
+ fm_rows = ""
569
+ if fm:
570
+ fm_rows = ("<table>\n<tbody>\n%s\n</tbody>\n</table>" % "\n".join(
571
+ "<tr><th>%s</th><td>%s</td></tr>" % (_html.escape(k), inline(v))
572
+ for k, v in fm.items() if v))
573
+
574
+ toc_items = "\n".join(
575
+ ' <li%s><a href="#%s">%s</a></li>'
576
+ % (' class="h3"' if lvl == 3 else "", slug, _html.escape(txt))
577
+ for lvl, slug, txt in r.toc)
578
+
579
+ inline_toc = ""
580
+ if r.h2 >= 2 and toc_items:
581
+ inline_toc = (' <div class="toc-inline">\n <div>Contents</div>\n'
582
+ ' <ol>\n%s\n </ol>\n </div>\n' % toc_items)
583
+
584
+ side = ""
585
+ layout_cls = " no-toc"
586
+ if r.h2 > 6:
587
+ layout_cls = ""
588
+ side = (' <nav class="side">\n <h1>%s</h1>\n'
589
+ ' <div class="sub">Last rendered: %s</div>\n'
590
+ ' <div class="group">Contents</div>\n <ol>\n%s\n </ol>\n'
591
+ ' </nav>\n' % (_html.escape(title), today, toc_items))
592
+
593
+ # Token replacement, NOT %-formatting or .format(): the §2 CSS and §7 JS are
594
+ # pasted in verbatim and are full of % (100%, max-width:100%) and { } — both
595
+ # of which those mechanisms would try to interpret.
596
+ skeleton = """<!doctype html>
597
+ <html lang="en">
598
+ <head>
599
+ <meta charset="utf-8">
600
+ <meta name="viewport" content="width=device-width,initial-scale=1">
601
+ <title>@@TITLE@@</title>
602
+ <script>
603
+ @@HEADJS@@
604
+ </script>
605
+ <style>
606
+ @@CSS@@
607
+ </style>
608
+ </head>
609
+ <body>
610
+ <button id="themeToggle" class="theme-toggle" title="Toggle light / dark">&#9790; Dark</button>
611
+ <div class="layout@@LAYOUT@@">
612
+ @@SIDE@@ <main>
613
+ <h1>@@TITLEH@@</h1>
614
+ <div class="subtitle">@@SUBTITLE@@</div>
615
+ @@CTA@@@@FM@@
616
+ @@INLINETOC@@@@BODY@@
617
+ </main>
618
+ </div>
619
+
620
+ <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
621
+ <script src="https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js"></script>
622
+ <script>
623
+ @@JS@@
624
+ </script>
625
+ </body>
626
+ </html>
627
+ """
628
+ doc = skeleton
629
+ for token, value in (("@@TITLE@@", _html.escape(title)),
630
+ ("@@TITLEH@@", inline(title)),
631
+ ("@@HEADJS@@", head_js),
632
+ ("@@CSS@@", css),
633
+ ("@@JS@@", body_js),
634
+ ("@@LAYOUT@@", layout_cls),
635
+ ("@@SIDE@@", side),
636
+ ("@@SUBTITLE@@", subtitle),
637
+ ("@@CTA@@", cta),
638
+ ("@@FM@@", fm_rows),
639
+ ("@@INLINETOC@@", inline_toc),
640
+ ("@@BODY@@", "\n".join(r.out))):
641
+ doc = doc.replace(token, value)
642
+
643
+ target_dir = out_dir or os.path.dirname(os.path.abspath(md_path))
644
+ # --out may name a directory that does not exist yet; create it rather than
645
+ # failing with a bare ENOENT on the output path.
646
+ if out_dir:
647
+ os.makedirs(target_dir, exist_ok=True)
648
+ out_path = os.path.join(target_dir, re.sub(r"\.md$", "", base) + ".html")
649
+ with open(out_path, "w", encoding="utf-8") as fh:
650
+ fh.write(doc)
651
+ return out_path, r, len(doc)
652
+
653
+
654
+ class Refused(Exception):
655
+ pass
656
+
657
+
658
+ def main(argv):
659
+ args = [a for a in argv if not a.startswith("--")]
660
+ quiet = "--quiet" in argv
661
+ out_dir = None
662
+ for a in argv:
663
+ if a.startswith("--out="):
664
+ out_dir = a.split("=", 1)[1]
665
+ spec_path = None
666
+ for a in argv:
667
+ if a.startswith("--spec="):
668
+ spec_path = a.split("=", 1)[1]
669
+
670
+ if not args:
671
+ print(__doc__.strip())
672
+ return 2
673
+
674
+ spec = find_spec(spec_path)
675
+ if not spec:
676
+ sys.stderr.write(
677
+ "tf-render-html: cannot find templates/v4custom/html-render-shell.md.\n"
678
+ " It ships in every installation at .tfcore/templates/v4custom/.\n"
679
+ " That path is hidden AND gitignored, so a search will not find it —\n"
680
+ " check the literal path, or run update-framework.sh <repo>.\n")
681
+ return 2
682
+
683
+ css, head_js, body_js = load_shell(spec)
684
+
685
+ rc = 0
686
+ for md in args:
687
+ if not os.path.isfile(md):
688
+ sys.stderr.write("tf-render-html: no such file: %s\n" % md)
689
+ rc = 1
690
+ continue
691
+ try:
692
+ out, r, size = render(md, css, head_js, body_js, out_dir)
693
+ except Refused as e:
694
+ sys.stderr.write("tf-render-html: REFUSED — %s\n" % e)
695
+ rc = 2
696
+ continue
697
+ except Exception as e: # noqa: BLE001
698
+ sys.stderr.write("tf-render-html: failed on %s: %s\n" % (md, e))
699
+ rc = 1
700
+ continue
701
+ if not quiet:
702
+ bits = ["%s (%.1f KB, %d H2" % (out, size / 1024.0, r.h2)]
703
+ bits.append("sidebar" if r.h2 > 6 else "no sidebar")
704
+ if r.diagrams:
705
+ bits.append("%d diagram%s" % (r.diagrams, "" if r.diagrams == 1 else "s"))
706
+ if r.stripped_notes:
707
+ bits.append("%d agent note(s) stripped per §6b" % r.stripped_notes)
708
+ print(" rendered %s)" % ", ".join(bits))
709
+ for lab in dict.fromkeys(r.bad_labels):
710
+ sys.stderr.write(" ⚠ mermaid §5.5: unquoted label or reserved id: %s\n" % lab)
711
+ return rc
712
+
713
+
714
+ if __name__ == "__main__":
715
+ sys.exit(main(sys.argv[1:]))