@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,233 @@
1
+ #!/usr/bin/env python3
2
+ """tf-owner-text.py — check text written for the owner (MISS-TechieFlow-20260911-03).
3
+
4
+ bash .tfcore/utils/tf-owner-text.sh [--root <dir>] [--final] <file> ...
5
+ bash .tfcore/utils/tf-owner-text.sh [--root <dir>] [--final] --stdin # the closing message
6
+
7
+ Four checks, one per mistake that reached the owner in TfLens's hand-off of 2026-09-11
8
+ (MISS-TfLens-20260911-01 to -03, and a fourth: two problems fixed upstream reported as open):
9
+
10
+ words a word from .tfcore/standards/owner-words.txt, or a script's internal name such as
11
+ duration_s or PHASES_TOP_KEYS, in a sentence the owner reads
12
+ upstream an upstream problem (an entry id in docs/*-Feedback.md: TF-022, TR-010 …) that is
13
+ still open sits in a table saying what it affects and whether it blocks or breaks
14
+ anything, and appears in a code block, which is the prompt that gets it fixed; one the
15
+ feedback file records as fixed upstream or closed is described as fixed or closed
16
+ commands a framework command named in a sentence (*verify) appears in a code block holding
17
+ the line to paste
18
+ final with --final, the text is a command's closing message and carries a code block: the
19
+ next prompt
20
+
21
+ Text inside ``` blocks is what the owner pastes, so the words check skips it. A document that has
22
+ its own template (BRD, checklist, feedback file …) is skipped: tf-doc-check.sh owns its shape.
23
+ Exit 0 clean, 1 a FAIL, 2 could not run. Python 3 standard library only.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import glob
28
+ import os
29
+ import re
30
+ import sys
31
+
32
+ HERE = os.path.dirname(os.path.abspath(__file__))
33
+ ANY_ID = re.compile(r"(?<![\w-])([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d+)\b")
34
+ SCRIPT_NAME = re.compile(r"(?<![\w/.\-])([A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+)(?![\w/\-]|\.\w)")
35
+ COMMAND = re.compile(r"(?<![\w*])\*([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\b")
36
+ # a sentence that puts a command in front of the owner as work still to do
37
+ TO_RUN = re.compile(r"(?i)\b(run|rerun|re-run|pending|next|not (?:yet )?(?:been )?run|still|should|must|"
38
+ r"needs?|to do|waiting|before|then)\b")
39
+ COMMANDS = {"verify", "build-phase", "fix-issues", "triage-issues", "triage-and-fix", "amend-docs",
40
+ "refresh-status", "handoff-phase", "log-miss", "deploy-checklist", "day1-greenfield",
41
+ "day1-brownfield", "devguide", "productguide", "metrics", "split-brd", "mockups", "yolo"}
42
+ TEMPLATED = re.compile(
43
+ r"(?i)(?:-P\d+)?-(BRD|Architecture|UIDesign|Checklist|Deployment-Checklist(?:-[\w]+)?|Coding-Standards|"
44
+ r"UsageGuide|Usage-Guide|DevGuide|ProductGuide|Phases|Brief|Decision-Request|[\w.]+-Feedback|Misses)\.md$"
45
+ r"|(?:^|/)PROJECT-STATUS\.md$")
46
+
47
+
48
+ def load_words(root):
49
+ for base in (os.path.join(root, ".tfcore", "standards"), os.path.join(HERE, "..", "standards")):
50
+ p = os.path.join(base, "owner-words.txt")
51
+ if os.path.isfile(p):
52
+ out = {}
53
+ for line in open(p, encoding="utf-8"):
54
+ if line.strip() and not line.lstrip().startswith("#") and "|" in line:
55
+ w, say = (x.strip() for x in line.split("|", 1))
56
+ out[w.lower()] = say
57
+ return out
58
+ return {}
59
+
60
+
61
+ def upstream_state(root):
62
+ """Every entry id in the project's feedback files -> (state, where), from the one reader
63
+ every script uses (tf_feedback.py), so this check and PROJECT-STATUS can never disagree."""
64
+ sys.path.insert(0, HERE)
65
+ import tf_feedback
66
+ return tf_feedback.state_map(root)
67
+
68
+
69
+ def split_text(text):
70
+ """-> (prose lines [(n, line)], the text inside code blocks)."""
71
+ prose, fenced, fence = [], [], None
72
+ for n, line in enumerate(text.splitlines(), 1):
73
+ m = re.match(r"^\s*(```|~~~)", line)
74
+ if m:
75
+ fence = None if fence == m.group(1) else (fence or m.group(1))
76
+ continue
77
+ (fenced if fence else prose).append((n, line) if not fence else line)
78
+ return prose, "\n".join(fenced)
79
+
80
+
81
+ def units(prose):
82
+ """Group prose lines into the unit a sentence is read in: a table row (with its table's
83
+ header) or a paragraph. -> [(first line no, text, header or None)]"""
84
+ out, para, header, in_table = [], [], None, False
85
+
86
+ def flush():
87
+ if para:
88
+ out.append((para[0][0], "\n".join(t for _n, t in para), None))
89
+ para.clear()
90
+
91
+ for n, line in prose:
92
+ if line.lstrip().startswith("|"):
93
+ flush()
94
+ if not in_table:
95
+ header, in_table = line, True
96
+ elif not re.match(r"^\s*\|?\s*:?-{2,}", line):
97
+ out.append((n, line, header))
98
+ continue
99
+ in_table = False
100
+ if line.strip():
101
+ para.append((n, line))
102
+ else:
103
+ flush()
104
+ flush()
105
+ return out
106
+
107
+
108
+ def next_command(root):
109
+ p = os.path.join(root, "PROJECT-STATUS.md")
110
+ if not os.path.isfile(p):
111
+ return ""
112
+ m = re.search(r"(?ms)^##\s+Next command to run.*?```[^\n]*\n(.*?)```", open(p, encoding="utf-8").read())
113
+ return m.group(1).strip().splitlines()[0] if m and m.group(1).strip() else ""
114
+
115
+
116
+ def check(text, label, root, final=False, words=None, ups=None):
117
+ fails = []
118
+ fail = lambda n, msg: fails.append(f"FAIL {label}{':' + str(n) if n else ''}: {msg}")
119
+ words = load_words(root) if words is None else words
120
+ ups = upstream_state(root) if ups is None else ups
121
+ prose, fenced = split_text(text)
122
+
123
+ # 1. words
124
+ seen = set()
125
+ for n, line in prose:
126
+ plain = re.sub(r"`[^`]*`", " ", re.sub(r"https?://\S+|\]\([^)]*\)", " ", line))
127
+ plain = re.sub(r'"[^"\n]{1,30}"|“[^”\n]{1,30}”', " ", plain) # a word quoted to name it is not used
128
+ for w, say in words.items():
129
+ if w not in seen and re.search(rf"(?i)(?<![\w-]){re.escape(w)}s?(?![\w-])", plain):
130
+ seen.add(w)
131
+ fail(n, f'"{w}" is a word the owner has to look up; say "{say}"')
132
+ for name in SCRIPT_NAME.findall(re.sub(r"https?://\S+|\]\([^)]*\)", " ", line)):
133
+ if name not in seen:
134
+ seen.add(name)
135
+ fail(n, f'"{name}" is a name inside a script; say in words what it is')
136
+
137
+ # 2. upstream problems. A fixed or closed one must read as such wherever it is named; an open
138
+ # one must be explained once, in a table row saying what it affects and whether it blocks or
139
+ # breaks anything, and must appear in the prompt that gets it fixed.
140
+ us = units(prose)
141
+ mentions = {}
142
+ for n, txt, header in us:
143
+ for rid in dict.fromkeys(ANY_ID.findall(txt)):
144
+ if rid in ups:
145
+ mentions.setdefault(rid, []).append((n, txt, header))
146
+ for rid, ms in sorted(mentions.items(), key=lambda kv: kv[1][0][0]):
147
+ st, where = ups[rid]
148
+ if st in ("fixed", "closed"):
149
+ # read the sentence that names it, or its table row: "TF-901 is filed. X is fixed." is wrong
150
+ said = lambda txt, h: [txt] if h is not None else [s for s in re.split(r"(?<=[.!?])\s+", txt) if rid in s]
151
+ wrong = next((n for n, txt, h in ms
152
+ if not all(re.search(r"(?i)\b(fixed|closed)\b", s) for s in said(txt, h))), None)
153
+ if wrong is not None:
154
+ fail(wrong, f"{rid} is written about as an open problem, but {where} records it as "
155
+ f"{'fixed upstream' if st == 'fixed' else 'closed'}; say so, and say what re-checks it here")
156
+ continue
157
+ explained = any(h is not None and "affect" in h.lower() and re.search(r"(?i)block|break", h)
158
+ for _n, _t, h in ms)
159
+ if not explained:
160
+ fail(ms[0][0], f"{rid} is named without saying what it affects and whether it blocks or breaks "
161
+ f"anything: put it in a table with the columns Problem | What it affects | Does it block or break anything")
162
+ elif not re.search(rf"(?<![\w-]){re.escape(rid)}\b", fenced):
163
+ fail(ms[0][0], f"{rid} is open and no code block gives the prompt that gets it fixed")
164
+
165
+ # 3. commands: one presented as something to run needs the line to paste. Naming a command
166
+ # to say what it did ("*build-phase means build every unfinished row") needs nothing.
167
+ known = COMMANDS | {os.path.splitext(os.path.basename(p))[0]
168
+ for p in glob.glob(os.path.join(root, ".tfcore", "tasks", "*.md"))
169
+ if not os.path.basename(p).startswith("_")}
170
+ named = {}
171
+ for n, txt, _h in us:
172
+ plain = re.sub(r"`[^`]*`", " ", txt) # `run-void` is a name, not a cue
173
+ plain = re.sub(r"(?i)\b(?:do not|don't|never|no need to|need not)\s+run\b", " ", plain)
174
+ if not TO_RUN.search(plain):
175
+ continue
176
+ for c in COMMAND.findall(txt):
177
+ if c in known:
178
+ named.setdefault(c, n)
179
+ for c, n in sorted(named.items(), key=lambda kv: kv[1]):
180
+ if not re.search(rf"\*{re.escape(c)}\b", fenced):
181
+ fail(n, f"*{c} is named, but no code block holds the line to paste for it "
182
+ f"(tf-status-facts.sh prints it in both harness forms)")
183
+
184
+ # 4. a command's closing message ends by handing over the next prompt
185
+ if final and not fenced.strip():
186
+ nc = next_command(root)
187
+ fail(0, "the closing message gives no next prompt; end with a code block holding the line to paste"
188
+ + (f" — PROJECT-STATUS.md says: {nc}" if nc else ""))
189
+ return fails
190
+
191
+
192
+ def main(argv):
193
+ root, final, stdin, files = os.getcwd(), False, False, []
194
+ it = iter(argv)
195
+ for a in it:
196
+ if a == "--root":
197
+ root = os.path.abspath(next(it, "."))
198
+ elif a == "--final":
199
+ final = True
200
+ elif a == "--stdin":
201
+ stdin = True
202
+ elif a in ("-h", "--help"):
203
+ print(__doc__)
204
+ return 0
205
+ else:
206
+ files.append(a)
207
+ if not stdin and not files:
208
+ print(__doc__)
209
+ return 2
210
+ words, ups, fails, checked = load_words(root), upstream_state(root), [], 0
211
+ if stdin:
212
+ fails += check(sys.stdin.read(), "the closing message", root, final, words, ups)
213
+ checked += 1
214
+ for f in files:
215
+ rel = os.path.relpath(os.path.abspath(f), root)
216
+ if TEMPLATED.search(rel.replace("\\", "/")):
217
+ print(f"SKIP {rel}: it has a template; tf-doc-check.sh checks it")
218
+ continue
219
+ if not os.path.isfile(f):
220
+ fails.append(f"FAIL {rel}: file not found")
221
+ continue
222
+ # a document handed over is held to the first three checks; ending on the next prompt is
223
+ # the closing message's job
224
+ fails += check(open(f, encoding="utf-8", errors="replace").read(), rel, root, False, words, ups)
225
+ checked += 1
226
+ for line in fails:
227
+ print(line)
228
+ print(f"tf-owner-text: {len(fails)} FAIL in {checked} text(s)")
229
+ return 1 if fails else 0
230
+
231
+
232
+ if __name__ == "__main__":
233
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bash
2
+ # tf-owner-text.sh — check text written for the owner: plain words, every upstream problem with
3
+ # what it affects and the prompt that fixes it, every named command with the line to paste, and
4
+ # a command's closing message ending on the next prompt (MISS-TechieFlow-20260911-03).
5
+ #
6
+ # bash .tfcore/utils/tf-owner-text.sh [--root <dir>] [--final] <file> ...
7
+ # printf '%s' "<message>" | bash .tfcore/utils/tf-owner-text.sh --final --stdin
8
+ #
9
+ # The Stop hook runs it on a command's closing message and on every free-form document that
10
+ # message hands the owner. Details: tf-owner-text.py. Exit 0 clean, 1 a FAIL, 2 could not run.
11
+ set -u
12
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ command -v python3 >/dev/null 2>&1 || { echo "tf-owner-text: python3 is required" >&2; exit 2; }
14
+ exec python3 "$HERE/tf-owner-text.py" "$@"
@@ -38,7 +38,18 @@ case "${1:-}" in
38
38
  NOW="$(sed -n 's/.*"started":"\([^"]*\)".*/\1/p' "$FILE")"
39
39
  echo "tf-phase: started taken from the supervisor's marker ($NOW)" >&2
40
40
  fi
41
- printf '{"cmd":"%s","app":"%s","started":"%s"}\n' "$CMD" "$APP" "$NOW" > "$FILE"
41
+ # A command started inside another one (*fix-issues and *build-phase run *verify inline) keeps
42
+ # the outer command as "outer", written FIRST so a greedy `.*"started":"…"` read still takes this
43
+ # command's own start. tf-verify-emit.sh and tf-fix-close.sh read it to tell the two starts
44
+ # apart (TF-052: the inline verify took the fix's start and its record swallowed the fix's).
45
+ OUTER=""
46
+ if [[ -f "$FILE" && -z "$(find "$FILE" -mmin +1440 2>/dev/null)" ]]; then
47
+ OC="$(sed -n 's/.*"cmd":"\([^"]*\)".*/\1/p' "$FILE")"; OA="$(sed -n 's/.*"app":"\([^"]*\)".*/\1/p' "$FILE")"
48
+ OS="$(sed -n 's/.*"started":"\([^"]*\)".*/\1/p' "$FILE")"
49
+ [[ -n "$OC" && -n "$OS" && "$OC" != "$CMD" && "$OC" != "goal" && "$OS" != "$NOW" ]] \
50
+ && OUTER="\"outer\":{\"cmd\":\"$OC\",\"app\":\"$OA\",\"started\":\"$OS\"},"
51
+ fi
52
+ printf '{%s"cmd":"%s","app":"%s","started":"%s"}\n' "$OUTER" "$CMD" "$APP" "$NOW" > "$FILE"
42
53
  echo "$NOW"
43
54
  echo "tf-phase: $CMD${APP:+ $APP} started $NOW (marker .tfcore/.session/phase.json)" >&2
44
55
  # The document-check baseline (Sitting 4c, 2026-09-06): the findings the checklists and the
@@ -51,6 +62,27 @@ case "${1:-}" in
51
62
  ( cd "$ROOT" && bash .tfcore/utils/tf-doc-check.sh --root "$ROOT" --baseline-write "${docs[@]}" 2>/dev/null | tail -1 >&2 ) || true
52
63
  fi
53
64
  fi
65
+ # The framework's own scripts, against THIS project's real files, before the command
66
+ # does anything (2026-09-09). Nine framework defects reached TfLens by being met
67
+ # mid-build, and each cost a switch to the framework repo and back. Step 0 is the one
68
+ # place every command already passes through, so the check lives here and no task file
69
+ # has to mention it -- including commands added later.
70
+ #
71
+ # It NEVER blocks: a framework defect is reported and the command carries on, exactly
72
+ # as a library gap does. Set TF_SKIP_SELFCHECK=1 to skip it (the self-tests do).
73
+ if [[ "${TF_SKIP_SELFCHECK:-0}" != "1" && -f "$ROOT/.tfcore/utils/tf-selfcheck.sh" ]]; then
74
+ sc="$(cd "$ROOT" && bash .tfcore/utils/tf-selfcheck.sh 2>/dev/null)" || true
75
+ if grep -q '^FAIL' <<<"$sc"; then
76
+ echo "tf-phase: the framework is broken on this project — log these in the feedback file and carry on:" >&2
77
+ grep '^FAIL' <<<"$sc" >&2
78
+ echo "tf-phase: entries to paste: bash .tfcore/utils/tf-selfcheck.sh --report" >&2
79
+ else
80
+ echo "tf-phase: framework self-check clean" >&2
81
+ fi
82
+ # upstream fixes this project has not re-checked: passed through whole, because "clean"
83
+ # alone let an agent report two fixed problems to the owner as open (2026-09-11)
84
+ sed -n '/^Fixed upstream, not yet re-checked here/,/tf-feedback.sh <App> --close/p' <<<"$sc" | sed 's/^/tf-phase: /' >&2
85
+ fi
54
86
  ;;
55
87
  goal)
56
88
  # written by tf-goal.sh at the start of a run's first cycle; claimed by the first `start`
@@ -0,0 +1,230 @@
1
+ # tf-portable.sh — the few commands that differ between Linux/WSL (GNU tools, bash 4+) and a
2
+ # stock Mac (bash 3.2 at /bin/bash, BSD sed/date/stat, no GNU coreutils). Sourced, not run:
3
+ # source "$(dirname "${BASH_SOURCE[0]}")/tf-portable.sh"
4
+ # Every function uses the native tool when it is there, so Linux and WSL behave exactly as they
5
+ # did before this file existed; the fallback runs only where the native tool is missing.
6
+ # Plain functions only: sourcing it defines functions and nothing else (no variables, no traps,
7
+ # no set options). Bash 3.2 safe. The check tests/portability/run.sh keeps GNU-only and
8
+ # bash-4-only constructs out of every other script; add a function here instead.
9
+ #
10
+ # tf_timeout DURATION CMD [ARG...] timeout(1); 124 when the time runs out
11
+ # tf_setsid [--exec] CMD [ARG...] setsid(1); --exec replaces the calling (sub)shell
12
+ # tf_sed_inplace SED-ARG... FILE sed -i on GNU and BSD sed, no backup file left behind
13
+ # tf_stat_mtime FILE modification time, epoch seconds
14
+ # tf_stat_size FILE size in bytes
15
+ # tf_date_from [-u] WHEN [+FORMAT] date -d WHEN; WHEN is @EPOCH, "-10 min", "+2 hours",
16
+ # "now", or YYYY-MM-DD[( |T)HH:MM[:SS]][Z]
17
+ # tf_epoch_frac seconds since the epoch with a fraction (date +%s.%N)
18
+ # tf_realpath PATH realpath(1) as GNU has it (the last part may be missing)
19
+ # tf_relpath BASE PATH realpath --relative-to=BASE PATH
20
+ # tf_read_lines ARRAY-NAME mapfile -t ARRAY-NAME (reads stdin)
21
+
22
+ # ---- tf_timeout ---------------------------------------------------------------------------
23
+ # GNU timeout, then Homebrew's gtimeout, then a perl fallback (perl ships with macOS). The
24
+ # fallback runs the command in its own process group, sends the group TERM when the time is up
25
+ # and exits 124, the code timeout(1) uses; otherwise it exits with the command's own status
26
+ # (128+N when signal N ended it), and 126/127 when the command cannot be run, as timeout(1) does.
27
+ tf_timeout() {
28
+ if [ $# -lt 2 ]; then echo "tf_timeout: usage: tf_timeout DURATION COMMAND [ARG...]" >&2; return 125; fi
29
+ if command -v timeout >/dev/null 2>&1; then timeout "$@"; return $?; fi
30
+ if command -v gtimeout >/dev/null 2>&1; then gtimeout "$@"; return $?; fi
31
+ _tf_timeout_perl "$@"
32
+ }
33
+ _tf_timeout_perl() {
34
+ local dur="$1" secs; shift
35
+ case "$dur" in
36
+ *s) secs="${dur%s}" ;;
37
+ *m) secs="$(awk -v n="${dur%m}" 'BEGIN { print n * 60 }')" ;;
38
+ *h) secs="$(awk -v n="${dur%h}" 'BEGIN { print n * 3600 }')" ;;
39
+ *d) secs="$(awk -v n="${dur%d}" 'BEGIN { print n * 86400 }')" ;;
40
+ *) secs="$dur" ;;
41
+ esac
42
+ case "$secs" in ''|*[!0-9.]*) echo "tf_timeout: invalid time interval '$dur'" >&2; return 125 ;; esac
43
+ perl -e '
44
+ use strict; use POSIX ();
45
+ my $secs = shift @ARGV;
46
+ my $pid = fork();
47
+ if (!defined $pid) { print STDERR "tf_timeout: fork failed: $!\n"; exit 125; }
48
+ if ($pid == 0) {
49
+ setpgrp(0, 0); # its own process group, so the whole tree is stopped, as timeout(1) does
50
+ exec { $ARGV[0] } @ARGV;
51
+ my $code = $!{ENOENT} ? 127 : 126;
52
+ print STDERR "tf_timeout: failed to run command \x27$ARGV[0]\x27: $!\n";
53
+ POSIX::_exit($code);
54
+ }
55
+ my $fired = 0;
56
+ $SIG{ALRM} = sub { $fired = 1; kill("TERM", -$pid) or kill("TERM", $pid); };
57
+ $SIG{TERM} = sub { kill "TERM", $pid; };
58
+ $SIG{INT} = sub { kill "INT", $pid; };
59
+ if ($secs > 0) {
60
+ my $whole = int($secs); $whole++ if $whole < $secs; # alarm takes whole seconds; round up
61
+ alarm($whole);
62
+ }
63
+ my $got;
64
+ do { $got = waitpid($pid, 0); } while ($got == -1 && $!{EINTR});
65
+ my $status = $?;
66
+ alarm(0);
67
+ exit 124 if $fired;
68
+ exit(128 + ($status & 127)) if $status & 127;
69
+ exit($status >> 8);
70
+ ' "$secs" "$@"
71
+ }
72
+
73
+ # ---- tf_setsid ----------------------------------------------------------------------------
74
+ # util-linux setsid, then python3 os.setsid, then perl POSIX::setsid. As setsid(1) does, a
75
+ # caller that already leads a process group is forked first and the parent returns 0 at once.
76
+ # --exec: replace the calling (sub)shell with it, like `exec setsid CMD`.
77
+ tf_setsid() {
78
+ local ex=""
79
+ if [ "${1:-}" = "--exec" ]; then ex=exec; shift; fi
80
+ if [ $# -lt 1 ]; then echo "tf_setsid: usage: tf_setsid [--exec] COMMAND [ARG...]" >&2; return 1; fi
81
+ if command -v setsid >/dev/null 2>&1; then $ex setsid "$@"
82
+ elif command -v python3 >/dev/null 2>&1; then $ex python3 -c "$(_tf_setsid_py)" "$@"
83
+ elif command -v perl >/dev/null 2>&1; then $ex perl -e "$(_tf_setsid_pl)" "$@"
84
+ else echo "tf_setsid: needs setsid, python3 or perl; none is on PATH" >&2; return 127
85
+ fi
86
+ }
87
+ _tf_setsid_py() {
88
+ printf '%s\n' \
89
+ 'import os, sys' \
90
+ 'if os.getpgrp() == os.getpid():' \
91
+ ' if os.fork() != 0:' \
92
+ ' os._exit(0)' \
93
+ 'os.setsid()' \
94
+ 'try:' \
95
+ ' os.execvp(sys.argv[1], sys.argv[1:])' \
96
+ 'except OSError as e:' \
97
+ ' sys.stderr.write("setsid: failed to execute %s: %s\n" % (sys.argv[1], e.strerror))' \
98
+ ' os._exit(127 if e.errno == 2 else 126)'
99
+ }
100
+ _tf_setsid_pl() {
101
+ printf '%s\n' \
102
+ 'use POSIX ();' \
103
+ 'if (getpgrp() == $$) { my $p = fork(); POSIX::_exit(0) if $p; }' \
104
+ 'POSIX::setsid();' \
105
+ 'exec { $ARGV[0] } @ARGV;' \
106
+ 'my $c = $!{ENOENT} ? 127 : 126;' \
107
+ 'print STDERR "setsid: failed to execute $ARGV[0]: $!\n";' \
108
+ 'POSIX::_exit($c);'
109
+ }
110
+
111
+ # ---- tf_sed_inplace -----------------------------------------------------------------------
112
+ # GNU sed takes -i alone; BSD sed needs -i '' for "no backup". The sed script itself must be
113
+ # one both seds understand (no \s, \+, \n in a replacement, 0,/re/ addresses).
114
+ tf_sed_inplace() {
115
+ if [ $# -lt 2 ]; then echo "tf_sed_inplace: usage: tf_sed_inplace SED-ARG... FILE" >&2; return 2; fi
116
+ if sed --version >/dev/null 2>&1; then sed -i "$@"; else sed -i '' "$@"; fi
117
+ }
118
+
119
+ # ---- tf_stat_mtime / tf_stat_size ---------------------------------------------------------
120
+ tf_stat_mtime() {
121
+ if stat -c %Y / >/dev/null 2>&1; then stat -c %Y "$1"; else stat -f %m "$1"; fi
122
+ }
123
+ tf_stat_size() {
124
+ if stat -c %s / >/dev/null 2>&1; then stat -c %s "$1"; else stat -f %z "$1"; fi
125
+ }
126
+
127
+ # ---- tf_date_from -------------------------------------------------------------------------
128
+ # GNU date reads almost anything with -d; BSD date reads @EPOCH with -r and a fixed layout
129
+ # with -j -f. The WHEN forms listed at the top cover every caller in this framework. On GNU
130
+ # date the WHEN is handed to date -d unchanged, so the output is exactly what it was.
131
+ tf_date_from() {
132
+ local utc="" when fmt epoch
133
+ if [ "${1:-}" = "-u" ]; then utc="-u"; shift; fi
134
+ when="${1:-}"; fmt="${2:-}"
135
+ if [ -z "$when" ]; then echo "tf_date_from: usage: tf_date_from [-u] WHEN [+FORMAT]" >&2; return 1; fi
136
+ if date -d @0 +%s >/dev/null 2>&1; then
137
+ if [ -n "$fmt" ]; then date $utc -d "$when" "$fmt"; else date $utc -d "$when"; fi
138
+ return $?
139
+ fi
140
+ epoch="$(_tf_date_epoch $utc "$when")" || return 1
141
+ if [ -n "$fmt" ]; then date $utc -r "$epoch" "$fmt"; else date $utc -r "$epoch"; fi
142
+ }
143
+ # WHEN → epoch seconds without GNU date -d. @EPOCH, now and relative offsets are plain
144
+ # arithmetic; an absolute date goes through BSD date -j -f (GNU date on a machine that has it).
145
+ _tf_date_epoch() {
146
+ local utc="" when now n unit mult value layout
147
+ if [ "${1:-}" = "-u" ]; then utc="-u"; shift; fi
148
+ when="$1"; now="$(date +%s)"
149
+ case "$when" in
150
+ now) echo "$now"; return 0 ;;
151
+ @*) echo "${when#@}"; return 0 ;;
152
+ esac
153
+ case "$when" in
154
+ [+-][0-9]*|[0-9]*[a-z]*)
155
+ n="$(printf '%s' "$when" | sed -E 's/^([+-]?[0-9]+).*/\1/')"
156
+ unit="$(printf '%s' "$when" | sed -E 's/^[+-]?[0-9]+[[:space:]]*//; s/[[:space:]]*ago$//')"
157
+ case "$unit" in
158
+ s|sec|secs|second|seconds) mult=1 ;;
159
+ min|mins|minute|minutes) mult=60 ;;
160
+ hour|hours) mult=3600 ;;
161
+ day|days) mult=86400 ;;
162
+ week|weeks) mult=604800 ;;
163
+ *) mult="" ;;
164
+ esac
165
+ if [ -n "$mult" ]; then
166
+ n="${n#+}"
167
+ case "$when" in *ago) n=$(( -n )) ;; esac
168
+ echo $(( now + n * mult )); return 0
169
+ fi ;;
170
+ esac
171
+ value="$when"; layout=""
172
+ case "$value" in *Z) value="${value%Z}"; utc="-u" ;; esac
173
+ value="$(printf '%s' "$value" | sed 's/T/ /')"
174
+ case "$value" in
175
+ [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) value="$value 00:00:00" ;;
176
+ [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]" "[0-9][0-9]:[0-9][0-9]) value="$value:00" ;;
177
+ esac
178
+ case "$value" in
179
+ [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]" "[0-9][0-9]:[0-9][0-9]:[0-9][0-9]) layout="%Y-%m-%d %H:%M:%S" ;;
180
+ esac
181
+ if [ -z "$layout" ]; then echo "date: invalid date '$when'" >&2; return 1; fi
182
+ if date -d @0 +%s >/dev/null 2>&1; then date $utc -d "$value" +%s; else date $utc -j -f "$layout" "$value" +%s; fi
183
+ }
184
+
185
+ # ---- tf_epoch_frac ------------------------------------------------------------------------
186
+ # GNU date +%s.%N; BSD date prints a literal N, so the fallback asks perl or python3.
187
+ tf_epoch_frac() {
188
+ local v
189
+ v="$(date +%s.%N 2>/dev/null)"
190
+ case "$v" in *N|'') ;; *) printf '%s\n' "$v"; return 0 ;; esac
191
+ if command -v perl >/dev/null 2>&1; then perl -MTime::HiRes=time -e 'printf("%.9f\n", time)'
192
+ else python3 -c 'import time; print("%.9f" % time.time())'
193
+ fi
194
+ }
195
+
196
+ # ---- tf_realpath / tf_relpath -------------------------------------------------------------
197
+ tf_realpath() {
198
+ if [ $# -ne 1 ]; then echo "tf_realpath: usage: tf_realpath PATH" >&2; return 1; fi
199
+ # GNU realpath (it has -m) only: the macOS 13+ realpath refuses a path whose last part does not
200
+ # exist yet, which GNU realpath and the fallback below both accept.
201
+ if realpath -m / >/dev/null 2>&1; then realpath "$1"; return $?; fi
202
+ python3 -c 'import os, sys
203
+ p = sys.argv[1]
204
+ if not os.path.exists(os.path.dirname(os.path.abspath(p)) or "/"):
205
+ sys.stderr.write("realpath: %s: No such file or directory\n" % p); sys.exit(1)
206
+ print(os.path.realpath(p))' "$1"
207
+ }
208
+ tf_relpath() {
209
+ if [ $# -ne 2 ]; then echo "tf_relpath: usage: tf_relpath BASE PATH" >&2; return 1; fi
210
+ if realpath --relative-to=/ / >/dev/null 2>&1; then realpath --relative-to="$1" "$2"; return $?; fi
211
+ python3 -c 'import os, sys
212
+ print(os.path.relpath(os.path.realpath(sys.argv[2]), os.path.realpath(sys.argv[1])))' "$1" "$2"
213
+ }
214
+
215
+ # ---- tf_read_lines ------------------------------------------------------------------------
216
+ # mapfile -t NAME < <(cmd) becomes tf_read_lines NAME < <(cmd)
217
+ # One element per line, newline removed, a last line without a newline kept, nothing else
218
+ # touched (leading blanks and backslashes stay). NAME is emptied first, as mapfile does.
219
+ tf_read_lines() {
220
+ case "${1:-}" in
221
+ ''|[0-9]*|*[!A-Za-z0-9_]*) echo "tf_read_lines: usage: tf_read_lines ARRAY-NAME < input" >&2; return 2 ;;
222
+ esac
223
+ local _tf_rl_line _tf_rl_i=0
224
+ eval "$1=()"
225
+ while IFS= read -r _tf_rl_line || [ -n "$_tf_rl_line" ]; do
226
+ eval "$1[\$_tf_rl_i]=\$_tf_rl_line"
227
+ _tf_rl_i=$(( _tf_rl_i + 1 ))
228
+ done
229
+ return 0
230
+ }
@@ -12,7 +12,7 @@ Usage:
12
12
  Writes a sibling <file>.html for each input. Exit codes:
13
13
  0 all rendered
14
14
  1 a render failed
15
- 2 refused (checklist, or missing spec)
15
+ 2 refused (checklist, miss log, feedback file, or missing spec)
16
16
 
17
17
  Dependency-free by design: Python 3 standard library only. The reference machine
18
18
  has no pandoc / python-markdown / node markdown library installed.
@@ -111,6 +111,27 @@ RAW_TAG = re.compile(
111
111
  re.I)
112
112
 
113
113
 
114
+ # Where the page being rendered will live, and every page this run will write (Lekhak TF-015). A
115
+ # relative link to a sibling .md is written as .html when that .html is there already or is written in
116
+ # the same run, so an index of a split guide opens the rendered pages, not raw markdown. A link to a
117
+ # markdown file that has no rendered twin (an agent document, a checklist) stays .md.
118
+ LINK_CTX = {"page_dir": None, "batch": set()}
119
+ MD_LINK = re.compile(r"^([^#?]+?)\.md((?:[?#].*)?)$", re.I)
120
+
121
+
122
+ def md_link_to_html(href):
123
+ if not LINK_CTX["page_dir"] or re.match(r"^[a-z][a-z0-9+.-]*:|^/|^#", href, re.I):
124
+ return href # a web link, an absolute path, an anchor
125
+ m = MD_LINK.match(href)
126
+ if not m:
127
+ return href
128
+ from urllib.parse import unquote
129
+ target = os.path.normpath(os.path.join(LINK_CTX["page_dir"], unquote(m.group(1)) + ".html"))
130
+ if os.path.isfile(target) or target in LINK_CTX["batch"]:
131
+ return m.group(1) + ".html" + m.group(2)
132
+ return href
133
+
134
+
114
135
  def inline(text):
115
136
  """Markdown inline -> HTML. Code spans and deliberate raw inline HTML are
116
137
  protected before escaping, so `<a id="d-req-ui-001"></a>` survives verbatim
@@ -136,7 +157,7 @@ def inline(text):
136
157
  lambda m: '<img src="%s" alt="%s" style="max-width:100%%">'
137
158
  % (m.group(2), m.group(1)), text)
138
159
  text = re.sub(r"\[([^\]]+)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)",
139
- lambda m: '<a href="%s">%s</a>' % (m.group(2), m.group(1)), text)
160
+ lambda m: '<a href="%s">%s</a>' % (md_link_to_html(m.group(2)), m.group(1)), text)
140
161
  text = re.sub(r"<(https?://[^>\s]+)>", r'<a href="\1">\1</a>', text)
141
162
 
142
163
  text = re.sub(r"\*\*\*(.+?)\*\*\*", r"<strong><em>\1</em></strong>", text, flags=re.S)
@@ -479,6 +500,42 @@ def is_requirements_checklist(base, raw):
479
500
  or "SINGLE SOURCE OF TRUTH" in head)
480
501
 
481
502
 
503
+ def is_miss_list(base, raw):
504
+ """Is this THE derived miss log, or an ordinary document about misses?
505
+
506
+ `docs/{AppName}-Misses.md` is rebuilt from `misses.jsonl` on every write and is
507
+ read by agents and by the owner in markdown. Rendering it produced an HTML copy
508
+ nobody opened, re-rendered on every miss, in every project — 20 of them across
509
+ the estate before this refusal existed (owner, 2026-09-08).
510
+
511
+ Identify the document, do not guess from the suffix, exactly as the checklist
512
+ rule does: the name must match AND the content must carry the generator's own
513
+ Source row. A hand-written note called `Release-Misses.md` has neither."""
514
+ if not re.search(r"-Misses\.md$", base, re.I):
515
+ return False
516
+ return "Rewritten by `tf-misses-md.sh`" in raw[:2000]
517
+
518
+
519
+ FEEDBACK_ENTRY = re.compile(r"^#{2,3}\s+[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d+\b", re.M)
520
+
521
+
522
+ def is_feedback_file(base, raw):
523
+ """Is this an upstream feedback file, or an ordinary document about feedback?
524
+
525
+ `docs/{App}-{Upstream}-Feedback.md` is read by the upstream's agent and by the
526
+ maintainer who fixes it, in markdown. Nobody opens an HTML copy; the status gate's
527
+ "every human document this command wrote" kept producing one anyway, and an owner
528
+ asked more than once for it to be deleted (Chatur TF-001).
529
+
530
+ Identify the document, as the two rules above do: the name must match AND the
531
+ content must carry the template's `| Upstream |` header row or an entry heading
532
+ that opens with its id (TF-001, TR-RAG-002). A customer-feedback summary has neither."""
533
+ if not re.search(r"-Feedback\.md$", base, re.I):
534
+ return False
535
+ head = raw[:8000]
536
+ return bool(re.search(r"^\|\s*Upstream\s*\|", head, re.M) or FEEDBACK_ENTRY.search(raw))
537
+
538
+
482
539
  NEXT_CMD_H2 = re.compile(r"^##\s+Next command to run\s*$", re.I)
483
540
 
484
541
 
@@ -541,8 +598,17 @@ def render(md_path, css, head_js, body_js, out_dir=None):
541
598
  raise Refused("%s is the requirements checklist — checklists are AI-agent "
542
599
  "working documents and are NEVER rendered to HTML "
543
600
  "(html-render-shell §0)." % base)
601
+ if is_miss_list(base, raw_probe):
602
+ raise Refused("%s is the derived miss log — it is read in markdown by agents "
603
+ "and by the owner, and is NEVER rendered to HTML "
604
+ "(html-render-shell §0)." % base)
605
+ if is_feedback_file(base, raw_probe):
606
+ raise Refused("%s is an upstream feedback file — it is read in markdown by the "
607
+ "upstream's agent, and is NEVER rendered to HTML "
608
+ "(html-render-shell §0)." % base)
544
609
 
545
610
  raw = raw_probe
611
+ LINK_CTX["page_dir"] = os.path.abspath(out_dir or os.path.dirname(os.path.abspath(md_path)))
546
612
  lines = raw.split("\n")
547
613
  fm, lines = frontmatter(lines)
548
614
 
@@ -682,6 +748,18 @@ def main(argv):
682
748
 
683
749
  css, head_js, body_js = load_shell(spec)
684
750
 
751
+ # the pages this run will write, for md_link_to_html (a file the §0 ban refuses writes none)
752
+ LINK_CTX["batch"] = set()
753
+ for md in args:
754
+ try:
755
+ b = os.path.basename(md)
756
+ probe = open(md, encoding="utf-8").read().replace("\r\n", "\n")
757
+ if not (is_requirements_checklist(b, probe) or is_miss_list(b, probe) or is_feedback_file(b, probe)):
758
+ LINK_CTX["batch"].add(os.path.normpath(os.path.join(
759
+ os.path.abspath(out_dir or os.path.dirname(os.path.abspath(md))), re.sub(r"\.md$", "", b) + ".html")))
760
+ except OSError:
761
+ pass
762
+
685
763
  rc = 0
686
764
  for md in args:
687
765
  if not os.path.isfile(md):