@event4u/agent-config 9.7.0 → 9.8.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 (185) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +144 -0
  3. package/README.md +5 -5
  4. package/dist/agent-src/commands/commit/in-chunks.md +8 -0
  5. package/dist/agent-src/commands/commit.md +17 -0
  6. package/dist/agent-src/commands/pr/create.md +4 -0
  7. package/dist/agent-src/contexts/override-system.md +75 -5
  8. package/dist/agent-src/rules/context-hygiene.md +2 -0
  9. package/dist/agent-src/rules/doc-screenshot-hygiene.md +97 -0
  10. package/dist/agent-src/rules/domain-safety-pii.md +1 -0
  11. package/dist/agent-src/rules/external-code-graph-interop.md +13 -3
  12. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
  13. package/dist/agent-src/rules/git-history-discipline.md +2 -0
  14. package/dist/agent-src/rules/language-and-tone.md +2 -0
  15. package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
  16. package/dist/agent-src/rules/media-governance-routing.md +2 -0
  17. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  18. package/dist/agent-src/rules/no-roadmap-references.md +3 -0
  19. package/dist/agent-src/rules/non-destructive-by-default.md +20 -0
  20. package/dist/agent-src/rules/onboarding-gate.md +2 -0
  21. package/dist/agent-src/rules/output-discipline.md +2 -0
  22. package/dist/agent-src/rules/persona-governance.md +2 -0
  23. package/dist/agent-src/rules/preservation-guard.md +3 -0
  24. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  25. package/dist/agent-src/rules/secret-vcs-guard.md +106 -0
  26. package/dist/agent-src/rules/security-sensitive-stop.md +1 -1
  27. package/dist/agent-src/rules/skill-quality.md +2 -0
  28. package/dist/agent-src/rules/source-confidentiality.md +2 -0
  29. package/dist/agent-src/rules/source-of-truth.md +2 -0
  30. package/dist/agent-src/rules/telegraph-speak.md +2 -0
  31. package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
  32. package/dist/agent-src/rules/tool-safety.md +1 -0
  33. package/dist/agent-src/rules/verify-before-complete.md +2 -0
  34. package/dist/agent-src/skills/adr-create/SKILL.md +17 -1
  35. package/dist/agent-src/skills/code-intelligence/SKILL.md +94 -0
  36. package/dist/agent-src/skills/gated-reach/SKILL.md +185 -0
  37. package/dist/agent-src/skills/gated-reach/evals/triggers.json +72 -0
  38. package/dist/agent-src/skills/image-analyser/SKILL.md +14 -12
  39. package/dist/agent-src/skills/image-editing/SKILL.md +1 -0
  40. package/dist/agent-src/skills/readme-writing/SKILL.md +6 -1
  41. package/dist/agent-src/skills/screenshot-hygiene/SKILL.md +171 -0
  42. package/dist/agent-src/skills/secrets-management/SKILL.md +44 -1
  43. package/dist/agent-src/skills/subagent-orchestration/prompts/adversarial-verification-council.md +29 -0
  44. package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge-two-stage.md +29 -0
  45. package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge.md +29 -0
  46. package/dist/agent-src/skills/subagent-orchestration/prompts/do-competitively.md +29 -0
  47. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-parallel.md +29 -0
  48. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-steps.md +29 -0
  49. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-worktrees.md +29 -0
  50. package/dist/agent-src/skills/subagent-orchestration/prompts/judge-with-debate.md +29 -0
  51. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +4 -0
  52. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  53. package/dist/agent-src/templates/github-workflows/secret-scan.yml +65 -0
  54. package/dist/cli/configRoot.js +112 -0
  55. package/dist/cli/configRoot.js.map +1 -0
  56. package/dist/cli/configRoot.test.js +97 -0
  57. package/dist/cli/configRoot.test.js.map +1 -0
  58. package/dist/cli/help.js +4 -1
  59. package/dist/cli/help.js.map +1 -1
  60. package/dist/cli/main.js +19 -2
  61. package/dist/cli/main.js.map +1 -1
  62. package/dist/cli/registry.js +1 -0
  63. package/dist/cli/registry.js.map +1 -1
  64. package/dist/discovery/deprecation-report.md +1 -1
  65. package/dist/discovery/discovery-manifest.json +161 -44
  66. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  67. package/dist/discovery/discovery-manifest.summary.md +4 -4
  68. package/dist/discovery/orphan-report.md +1 -1
  69. package/dist/discovery/packs.json +13 -8
  70. package/dist/discovery/trust-report.md +11 -11
  71. package/dist/discovery/workspaces.json +11 -6
  72. package/dist/install/install.mjs +16 -0
  73. package/dist/mcp/registry-manifest.json +3 -3
  74. package/dist/router.json +1 -1
  75. package/dist/server/app.js +10 -0
  76. package/dist/server/app.js.map +1 -1
  77. package/dist/server/routes/ping.js +18 -0
  78. package/dist/server/routes/ping.js.map +1 -1
  79. package/dist/server/schemas/settings.js +8 -0
  80. package/dist/server/schemas/settings.js.map +1 -1
  81. package/dist/server/serverInfo.js +6 -2
  82. package/dist/server/serverInfo.js.map +1 -1
  83. package/dist/server/token.js +11 -4
  84. package/dist/server/token.js.map +1 -1
  85. package/dist/server/writeRoot.js +15 -1
  86. package/dist/server/writeRoot.js.map +1 -1
  87. package/dist/shared/capabilities.js +33 -0
  88. package/dist/shared/capabilities.js.map +1 -0
  89. package/dist/shared/capabilities.test.js +39 -0
  90. package/dist/shared/capabilities.test.js.map +1 -0
  91. package/dist/ui/assets/{index-BiEZiZ31.js → index-CrmZ34Rg.js} +16 -16
  92. package/dist/ui/assets/index-CrmZ34Rg.js.map +1 -0
  93. package/dist/ui/index.html +14 -5
  94. package/docs/CLAIMS.md +62 -37
  95. package/docs/architecture.md +2 -2
  96. package/docs/benchmark.md +137 -0
  97. package/docs/catalog.md +8 -3
  98. package/docs/comparison.yaml +44 -2
  99. package/docs/contracts/local-server-ports.md +53 -0
  100. package/docs/contracts/no-runtime-boundary.md +1 -1
  101. package/docs/contracts/subagent-boundary.md +15 -0
  102. package/docs/customization.md +14 -3
  103. package/docs/decisions/ADR-016-installer-architecture.md +2 -0
  104. package/docs/decisions/ADR-088-no-external-runtime-federation.md +1 -1
  105. package/docs/decisions/ADR-094-agent-memory-layer-removal.md +1 -1
  106. package/docs/decisions/ADR-109-subagent-v1-contract.md +2 -0
  107. package/docs/decisions/ADR-110-discipline-profile-resolution-locus.md +2 -0
  108. package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +11 -3
  109. package/docs/decisions/ADR-124-embedded-engine-doctrine.md +255 -0
  110. package/docs/decisions/ADR-125-doc-screenshot-anonymization.md +131 -0
  111. package/docs/decisions/ADR-126-internet-reach-operator-tooling.md +254 -0
  112. package/docs/decisions/ADR-127-enforcement-claims-must-resolve.md +150 -0
  113. package/docs/decisions/ADR-128-executable-evidence-and-measured-escalation.md +201 -0
  114. package/docs/decisions/INDEX.md +21 -0
  115. package/docs/decisions/engine-reclassification-2026-07.md +104 -0
  116. package/docs/distribution/awesome-list-submission.md +1 -1
  117. package/docs/distribution/registries.md +36 -27
  118. package/docs/enforcement-by-host.md +7 -0
  119. package/docs/featured-skills.md +2 -2
  120. package/docs/getting-started-by-role.md +1 -1
  121. package/docs/getting-started.md +1 -1
  122. package/docs/governance-advantage.md +1 -1
  123. package/docs/guides/gated-platform-reads.md +221 -0
  124. package/docs/positioning-evidence.md +1 -1
  125. package/docs/proof.md +92 -16
  126. package/docs/threat-model.md +1 -0
  127. package/package.json +5 -2
  128. package/src/config/agent-settings.template.yml +43 -0
  129. package/src/config/reach-channels.yml +217 -0
  130. package/src/config/reach-prescriptions-intake.yml +200 -0
  131. package/src/scripts/_dispatch.bash +15 -0
  132. package/src/scripts/_lib/exec_evidence.ts +251 -0
  133. package/src/scripts/_lib/fixtures/secret-detector/negatives.txt +10 -0
  134. package/src/scripts/_lib/fixtures/secret-detector/positives.txt +11 -0
  135. package/src/scripts/_lib/kernel_rules.ts +46 -0
  136. package/src/scripts/_lib/reddit_thread_parse.ts +598 -0
  137. package/src/scripts/_lib/risk_paths.ts +160 -0
  138. package/src/scripts/_lib/secret_detector.test.ts +103 -0
  139. package/src/scripts/_lib/secret_detector.ts +319 -0
  140. package/src/scripts/_lib/tool_probe.ts +359 -0
  141. package/src/scripts/adr/regenerate_index.ts +81 -6
  142. package/src/scripts/audit_adr_coverage.ts +33 -0
  143. package/src/scripts/build_proof.ts +162 -0
  144. package/src/scripts/check_adr_frontmatter.ts +195 -0
  145. package/src/scripts/check_artefact_count_messaging.ts +4 -0
  146. package/src/scripts/check_backstop_debt.ts +200 -0
  147. package/src/scripts/check_claims.ts +188 -8
  148. package/src/scripts/check_comparison.ts +12 -0
  149. package/src/scripts/check_enforcement_coverage.ts +592 -0
  150. package/src/scripts/check_kernel_rule_bundle.ts +16 -12
  151. package/src/scripts/check_reach_channels.ts +407 -0
  152. package/src/scripts/check_reach_staleness.ts +364 -0
  153. package/src/scripts/check_secret_leak.test.ts +47 -0
  154. package/src/scripts/check_secret_leak.ts +290 -0
  155. package/src/scripts/code_graph/build.ts +0 -0
  156. package/src/scripts/code_graph/cli.ts +207 -0
  157. package/src/scripts/code_graph/detect.ts +134 -0
  158. package/src/scripts/code_graph/extract.ts +386 -0
  159. package/src/scripts/code_graph/loader.ts +123 -0
  160. package/src/scripts/code_graph/query.ts +176 -0
  161. package/src/scripts/code_graph/sanitize.ts +30 -0
  162. package/src/scripts/code_graph/types.ts +83 -0
  163. package/src/scripts/code_graph/validate.ts +63 -0
  164. package/src/scripts/external_sources_denylist.json +3 -1
  165. package/src/scripts/generate_subagent_floor.ts +194 -0
  166. package/src/scripts/hook_manifest.yaml +13 -3
  167. package/src/scripts/hooks/code_graph_nudge_hook.ts +184 -0
  168. package/src/scripts/iron_law_sha.ts +3 -12
  169. package/src/scripts/lint_override_kernel_guard.ts +219 -0
  170. package/src/scripts/measure_rule_budget.ts +3 -12
  171. package/src/scripts/postinstall_gui.ts +129 -0
  172. package/src/scripts/reach_doctor.ts +1651 -0
  173. package/src/scripts/release.ts +24 -3
  174. package/src/scripts/risk_escalation_shadow.ts +160 -0
  175. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  176. package/src/scripts/schemas/reach-channels.schema.json +134 -0
  177. package/src/scripts/schemas/reach-doctor-payload.schema.json +220 -0
  178. package/src/scripts/schemas/rule.schema.json +10 -0
  179. package/src/scripts/skill_linter.ts +55 -0
  180. package/src/scripts/validate_reach_prescriptions.ts +751 -0
  181. package/src/server/schemas/settings.ts +16 -0
  182. package/src/shared/capabilities.test.ts +43 -0
  183. package/src/shared/capabilities.ts +75 -0
  184. package/src/templates/minimal/agents-overrides-readme.md +20 -0
  185. package/dist/ui/assets/index-BiEZiZ31.js.map +0 -1
@@ -0,0 +1,598 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * reddit_thread_parse.ts — deterministic old.reddit thread → JSON extractor.
4
+ *
5
+ * WHY THIS EXISTS. Reach research needs the *shape* of a discussion (who said
6
+ * what, to whom, and how well it landed), not a screenshot. The public JSON
7
+ * endpoints are rate-limited and auth-gated; the rendered `old.reddit.com` HTML
8
+ * is not. So this reads that HTML and emits a stable record.
9
+ *
10
+ * DETERMINISTIC BY CONSTRUCTION. stdin → stdout. No network, no filesystem
11
+ * write, no clock, no environment read, no state. The same bytes in always
12
+ * produce the same bytes out, which is what makes the committed fixtures under
13
+ * `tests/fixtures/reddit-thread/` a real regression lock rather than a smoke
14
+ * test. Fetching is the CALLER's job (`curl … | tsx this-file`) — keeping the
15
+ * request outside this module is what keeps it testable offline.
16
+ *
17
+ * NESTING IS THE POINT — NOT A FLAT REGEX. old.reddit renders a reply as a
18
+ * `div.thing` inside its parent's `div.child > div.sitetable.listing`. Reply
19
+ * order and parent linkage ARE the signal being extracted, so `depth` and
20
+ * `parent_id` come from a real tag-stack walk over the container structure
21
+ * (`_scanDivs` → `_collectComments`). A regex over `data-fullname` would return
22
+ * the right ids in the wrong tree and quietly destroy the only thing worth
23
+ * reading. There is no DOM library in this package's runtime dependencies
24
+ * (`happy-dom` is dev-only, and adding a dep for this is not warranted), hence
25
+ * the hand-rolled scanner — which also skips `<script>` / `<style>` bodies and
26
+ * HTML comments so markup inside them cannot shift the depth count.
27
+ *
28
+ * NULL OVER GUESSES. A score-hidden comment yields `score: null`, never `0` —
29
+ * old.reddit omits the `span.score.unvoted` entirely for those, and reporting
30
+ * `0` would invent a downvoted comment that does not exist. Same for a missing
31
+ * or `[deleted]` author.
32
+ *
33
+ * HOSTILE INPUT BY DEFINITION. This parses third-party HTML that any anonymous
34
+ * user can write into. Every emitted string therefore passes `sanitize_text`
35
+ * (the shared retrieval floor) which strips bidi / zero-width / Unicode-Tag
36
+ * hidden-instruction vectors and control-char noise. Visible text is preserved
37
+ * verbatim as DATA — a comment body that reads like an instruction stays inert
38
+ * prose in a JSON string, and is never treated as one (untrusted-input floor).
39
+ *
40
+ * Entities are decoded to a FIXED POINT (bounded, `_ENTITY_PASSES`): measured
41
+ * reality on this content is that `&amp;#32;` survives a single unescape pass,
42
+ * so one pass would leak a literal `&#32;` into the output. Decoding runs
43
+ * BEFORE sanitizing, so a numeric escape cannot smuggle a hidden codepoint past
44
+ * the floor.
45
+ *
46
+ * Output shape (stdout, one JSON object):
47
+ * { thread: { title, author, score, permalink },
48
+ * comments: [ { id, author, score, depth, parent_id, body } ],
49
+ * login_wall: boolean }
50
+ *
51
+ * Exit codes:
52
+ * 0 — parsed (including a legitimately empty / login-walled page).
53
+ * 3 — unusable input: nothing on stdin.
54
+ *
55
+ * Invocation:
56
+ * curl -sSL … 'https://old.reddit.com/r/x/comments/ID/' \
57
+ * | tsx src/scripts/_lib/reddit_thread_parse.ts
58
+ */
59
+ import process from 'node:process';
60
+ import { fileURLToPath, pathToFileURL } from 'node:url';
61
+ import * as fs from 'node:fs';
62
+ import * as path from 'node:path';
63
+
64
+ import { sanitize_text } from './retrieval_sanitize.js';
65
+
66
+ /** Header fields of the submission itself. `null` for anything not present. */
67
+ export interface ThreadHeader {
68
+ readonly title: string | null;
69
+ readonly author: string | null;
70
+ readonly score: number | null;
71
+ readonly permalink: string | null;
72
+ }
73
+
74
+ /** One comment, located in the reply tree by `depth` + `parent_id`. */
75
+ export interface ParsedComment {
76
+ /** Reddit fullname, e.g. `t1_onfj038` — unique within the page. */
77
+ readonly id: string;
78
+ /** `null` when absent or `[deleted]` — never a placeholder string. */
79
+ readonly author: string | null;
80
+ /** `null` when the score is hidden — never `0`. */
81
+ readonly score: number | null;
82
+ /** 0 for a top-level comment; +1 per enclosing comment container. */
83
+ readonly depth: number;
84
+ /** Fullname of the enclosing comment, or `null` at top level. */
85
+ readonly parent_id: string | null;
86
+ /** Visible body text, entity-decoded then sanitized. */
87
+ readonly body: string;
88
+ }
89
+
90
+ export interface ParsedThread {
91
+ readonly thread: ThreadHeader;
92
+ readonly comments: ParsedComment[];
93
+ /**
94
+ * True when the page is a login / interstitial rather than a thread —
95
+ * detected structurally (no comment nodes AND a login marker), so a real
96
+ * thread that merely links to `/login` is never misread as a wall.
97
+ */
98
+ readonly login_wall: boolean;
99
+ }
100
+
101
+ /**
102
+ * Entity-decode passes. Two are needed in practice (`&amp;#32;` → `&#32;` →
103
+ * ` `); the third is headroom. Bounded on purpose — an unbounded loop over
104
+ * attacker-supplied text is a denial-of-service lever.
105
+ */
106
+ const _ENTITY_PASSES = 3;
107
+
108
+ const _NAMED_ENTITIES: Readonly<Record<string, string>> = {
109
+ amp: '&',
110
+ lt: '<',
111
+ gt: '>',
112
+ quot: '"',
113
+ apos: "'",
114
+ nbsp: ' ',
115
+ // Reddit's markdown renderer emits these directly.
116
+ hellip: '…',
117
+ mdash: '—',
118
+ ndash: '–',
119
+ lsquo: '‘',
120
+ rsquo: '’',
121
+ ldquo: '“',
122
+ rdquo: '”',
123
+ };
124
+
125
+ /** One entity-decode pass: named + decimal + hex numeric references. */
126
+ function _decodeOnce(text: string): string {
127
+ return text.replace(/&(#[Xx]?[0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]{1,31});/g, (whole, bodyRaw: string) => {
128
+ const body = bodyRaw;
129
+ if (body.startsWith('#')) {
130
+ const isHex = body[1] === 'x' || body[1] === 'X';
131
+ const digits = isHex ? body.slice(2) : body.slice(1);
132
+ if (digits === '') {
133
+ return whole;
134
+ }
135
+ const cp = Number.parseInt(digits, isHex ? 16 : 10);
136
+ // Surrogates and out-of-range values are not codepoints; leave the
137
+ // reference visible rather than emitting a replacement char.
138
+ if (!Number.isFinite(cp) || cp < 0 || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) {
139
+ return whole;
140
+ }
141
+ return String.fromCodePoint(cp);
142
+ }
143
+ const named = _NAMED_ENTITIES[body.toLowerCase()];
144
+ return named ?? whole;
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Decode HTML entities to a fixed point (at most `_ENTITY_PASSES` passes).
150
+ * Stops as soon as a pass is a no-op, so well-formed input costs one pass.
151
+ */
152
+ export function decodeEntities(text: string): string {
153
+ let current = text;
154
+ for (let pass = 0; pass < _ENTITY_PASSES; pass += 1) {
155
+ const next = _decodeOnce(current);
156
+ if (next === current) {
157
+ return current;
158
+ }
159
+ current = next;
160
+ }
161
+ return current;
162
+ }
163
+
164
+ /** Decode-then-sanitize. The ONLY way a string reaches the output. */
165
+ function _clean(text: string): string {
166
+ return sanitize_text(decodeEntities(text));
167
+ }
168
+
169
+ interface DivToken {
170
+ readonly isEnd: boolean;
171
+ /** Raw attribute text of an open tag (`''` for a close tag). */
172
+ readonly attrs: string;
173
+ /** Offset of `<`. */
174
+ readonly start: number;
175
+ /** Offset just past `>`. */
176
+ readonly end: number;
177
+ }
178
+
179
+ const _RAW_TEXT_TAGS = new Set(['script', 'style', 'textarea', 'title']);
180
+
181
+ /**
182
+ * Walk every tag in the document and yield the `div` tokens in order.
183
+ *
184
+ * Every tag is parsed — not just divs — because only a full walk knows where a
185
+ * tag actually ends: a quoted attribute may contain `>` (old.reddit ships
186
+ * inline `onclick=` handlers), and `<script>` / `<style>` / `<title>` bodies
187
+ * are raw text where a literal `<div` must NOT count as an element. Skipping
188
+ * those is what keeps the depth arithmetic honest.
189
+ */
190
+ function _scanDivs(html: string): DivToken[] {
191
+ const tokens: DivToken[] = [];
192
+ const length = html.length;
193
+ let index = 0;
194
+
195
+ while (index < length) {
196
+ const lt = html.indexOf('<', index);
197
+ if (lt === -1) {
198
+ break;
199
+ }
200
+
201
+ // HTML comment / CDATA / doctype-ish bogus comment.
202
+ if (html.startsWith('<!--', lt)) {
203
+ const close = html.indexOf('-->', lt + 4);
204
+ index = close === -1 ? length : close + 3;
205
+ continue;
206
+ }
207
+ if (html.startsWith('<!', lt) || html.startsWith('<?', lt)) {
208
+ const close = html.indexOf('>', lt + 2);
209
+ index = close === -1 ? length : close + 1;
210
+ continue;
211
+ }
212
+
213
+ const isEnd = html[lt + 1] === '/';
214
+ const nameStart = lt + (isEnd ? 2 : 1);
215
+ const nameMatch = /^[A-Za-z][A-Za-z0-9-]*/.exec(html.slice(nameStart, nameStart + 32));
216
+ if (!nameMatch) {
217
+ // A stray `<` in text — not a tag. Step past it.
218
+ index = lt + 1;
219
+ continue;
220
+ }
221
+ const name = nameMatch[0].toLowerCase();
222
+
223
+ // Find the tag end, honouring quoted attribute values.
224
+ let cursor = nameStart + nameMatch[0].length;
225
+ let quote: string | null = null;
226
+ let tagEnd = -1;
227
+ while (cursor < length) {
228
+ const ch = html[cursor];
229
+ if (quote !== null) {
230
+ if (ch === quote) {
231
+ quote = null;
232
+ }
233
+ } else if (ch === '"' || ch === "'") {
234
+ quote = ch;
235
+ } else if (ch === '>') {
236
+ tagEnd = cursor;
237
+ break;
238
+ }
239
+ cursor += 1;
240
+ }
241
+ if (tagEnd === -1) {
242
+ break; // truncated tag at EOF
243
+ }
244
+
245
+ const attrs = html.slice(nameStart + nameMatch[0].length, tagEnd);
246
+ index = tagEnd + 1;
247
+
248
+ if (!isEnd && _RAW_TEXT_TAGS.has(name) && !attrs.trimEnd().endsWith('/')) {
249
+ // Raw-text element: everything up to its close tag is text.
250
+ const closeRe = new RegExp(`</${name}\\s*>`, 'i');
251
+ const rest = html.slice(index);
252
+ const closeMatch = closeRe.exec(rest);
253
+ index = closeMatch ? index + closeMatch.index + closeMatch[0].length : length;
254
+ continue;
255
+ }
256
+
257
+ if (name === 'div') {
258
+ tokens.push({ isEnd, attrs: isEnd ? '' : attrs, start: lt, end: index });
259
+ }
260
+ }
261
+
262
+ return tokens;
263
+ }
264
+
265
+ /** Read one attribute out of a raw attribute string. */
266
+ function _attr(attrs: string, key: string): string | null {
267
+ const re = new RegExp(`\\b${key}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i');
268
+ const match = re.exec(attrs);
269
+ if (!match) {
270
+ return null;
271
+ }
272
+ return match[2] ?? match[3] ?? null;
273
+ }
274
+
275
+ /** True when the tag's `class` attribute carries the given whole word. */
276
+ function _hasClass(attrs: string, className: string): boolean {
277
+ const raw = _attr(attrs, 'class');
278
+ if (raw === null) {
279
+ return false;
280
+ }
281
+ // Reddit writes `class=" thing id-x &#32; comment "` — the entity is a
282
+ // separator, so decode before word-splitting.
283
+ return decodeEntities(raw).split(/\s+/).includes(className);
284
+ }
285
+
286
+ interface CommentNode {
287
+ readonly id: string;
288
+ readonly attrs: string;
289
+ readonly depth: number;
290
+ readonly parent_id: string | null;
291
+ /** Offset just past this comment's own open tag. */
292
+ readonly contentStart: number;
293
+ /** Offset just past its matching `</div>`; `html.length` if unbalanced. */
294
+ contentEnd: number;
295
+ /** Start offset of its first descendant comment, if any. */
296
+ firstChildStart: number | null;
297
+ }
298
+
299
+ /**
300
+ * Tag-stack walk producing every comment node with a REAL `depth` /
301
+ * `parent_id` taken from container nesting.
302
+ *
303
+ * A `div.thing[data-type="comment"]` is a comment. Sibling `data-type`s
304
+ * (`morechildren` stubs, the `link` header) are deliberately not: the
305
+ * `morechildren` stub reuses its parent's `id`, so keying off `id` alone would
306
+ * emit a duplicate node.
307
+ */
308
+ function _collectComments(html: string): CommentNode[] {
309
+ const tokens = _scanDivs(html);
310
+ const nodes: CommentNode[] = [];
311
+ /** Every open div, by nesting order. `null` for a non-comment div. */
312
+ const divStack: (CommentNode | null)[] = [];
313
+ /** The open comment ancestors, innermost last. */
314
+ const commentStack: CommentNode[] = [];
315
+
316
+ for (const token of tokens) {
317
+ if (token.isEnd) {
318
+ const popped = divStack.pop();
319
+ if (popped) {
320
+ popped.contentEnd = token.start;
321
+ commentStack.pop();
322
+ }
323
+ continue;
324
+ }
325
+
326
+ const isComment = _attr(token.attrs, 'data-type') === 'comment' && _hasClass(token.attrs, 'thing');
327
+ if (!isComment) {
328
+ divStack.push(null);
329
+ continue;
330
+ }
331
+
332
+ const parent = commentStack[commentStack.length - 1] ?? null;
333
+ const id = _attr(token.attrs, 'data-fullname') ?? _attr(token.attrs, 'id') ?? `#${nodes.length}`;
334
+ const node: CommentNode = {
335
+ id,
336
+ attrs: token.attrs,
337
+ depth: commentStack.length,
338
+ parent_id: parent?.id ?? null,
339
+ contentStart: token.end,
340
+ contentEnd: html.length,
341
+ firstChildStart: null,
342
+ };
343
+ // Ancestors record where their own content stops and replies begin.
344
+ for (const ancestor of commentStack) {
345
+ if (ancestor.firstChildStart === null) {
346
+ ancestor.firstChildStart = token.start;
347
+ }
348
+ }
349
+ nodes.push(node);
350
+ divStack.push(node);
351
+ commentStack.push(node);
352
+ }
353
+
354
+ return nodes;
355
+ }
356
+
357
+ /**
358
+ * The slice of a comment that belongs to IT and not to its replies — from just
359
+ * past its open tag to the start of its first nested reply.
360
+ *
361
+ * This is defence-in-depth, NOT a fix for an observed bug — measured, and the
362
+ * measurement is recorded here so nobody re-derives it: removing the cut changes
363
+ * **nothing** on either the committed fixture (15 comments) or a 134-comment live
364
+ * thread spanning 7 depths — 0 differing fields. The reason is that both
365
+ * `_extractScore` and `_extractBody` take the FIRST match in the region, and a
366
+ * comment's own score span and body always precede its first child's.
367
+ *
368
+ * Keep the cut anyway: it is two lines, and it is what makes those extractors free
369
+ * to stop being first-match (a last-match, a max-of, or a multi-capture variant
370
+ * would silently start reading the child's values without it). Do not add a test
371
+ * asserting it — there is no behavioural difference to assert.
372
+ */
373
+ function _ownRegion(html: string, node: CommentNode): string {
374
+ const end = node.firstChildStart ?? node.contentEnd;
375
+ return html.slice(node.contentStart, Math.max(node.contentStart, end));
376
+ }
377
+
378
+ /**
379
+ * Score from the `unvoted` variant — the only one that is the real number
380
+ * (`dislikes` is score−1, `likes` is score+1). Absent → `null`, because
381
+ * old.reddit omits the span entirely on a score-hidden comment.
382
+ */
383
+ function _extractScore(region: string): number | null {
384
+ const match = /<(?:span|div)\b[^>]*\bclass\s*=\s*"[^"]*\bscore\b[^"]*\bunvoted\b[^"]*"[^>]*\btitle\s*=\s*"(-?\d+)"/i.exec(
385
+ region,
386
+ );
387
+ if (!match?.[1]) {
388
+ return null;
389
+ }
390
+ const value = Number.parseInt(match[1], 10);
391
+ return Number.isFinite(value) ? value : null;
392
+ }
393
+
394
+ const _BLOCK_BREAK_RE =
395
+ /<\s*\/?\s*(?:p|br|div|blockquote|li|ul|ol|h[1-6]|pre|tr|table|hr)\b[^>]*>/gi;
396
+
397
+ /** Markup → visible text. Block boundaries become newlines; tags vanish. */
398
+ function _htmlToText(fragment: string): string {
399
+ return fragment
400
+ .replace(_BLOCK_BREAK_RE, '\n')
401
+ .replace(/<[^>]*>/g, '')
402
+ .replace(/[ \t]+\n/g, '\n')
403
+ .replace(/\n{3,}/g, '\n\n')
404
+ .trim();
405
+ }
406
+
407
+ /**
408
+ * The comment's markdown body. Takes the FIRST `div.md` in the own-region:
409
+ * the rendered body precedes both the reply form's empty template and any
410
+ * nested reply, so "first" is unambiguous.
411
+ */
412
+ function _extractBody(region: string): string {
413
+ const open = /<div\b[^>]*\bclass\s*=\s*"[^"]*\bmd\b[^"]*"[^>]*>/i.exec(region);
414
+ if (!open) {
415
+ return '';
416
+ }
417
+ // Balanced-div walk so a nested <div> inside the body (code block, table)
418
+ // does not truncate it at the first </div>.
419
+ const bodyStart = open.index + open[0].length;
420
+ const tokens = _scanDivs(region.slice(bodyStart));
421
+ let depth = 0;
422
+ let bodyEnd = region.length - bodyStart;
423
+ for (const token of tokens) {
424
+ if (token.isEnd) {
425
+ if (depth === 0) {
426
+ bodyEnd = token.start;
427
+ break;
428
+ }
429
+ depth -= 1;
430
+ } else {
431
+ depth += 1;
432
+ }
433
+ }
434
+ return _htmlToText(region.slice(bodyStart, bodyStart + bodyEnd));
435
+ }
436
+
437
+ /** `[deleted]` / `[removed]` are placeholders, not authors → `null`. */
438
+ function _normalizeAuthor(raw: string | null): string | null {
439
+ if (raw === null) {
440
+ return null;
441
+ }
442
+ const author = _clean(raw).trim();
443
+ if (author === '' || author === '[deleted]' || author === '[removed]') {
444
+ return null;
445
+ }
446
+ return author;
447
+ }
448
+
449
+ /** Header of the submission — from the `data-type="link"` thing. */
450
+ function _extractThread(html: string): ThreadHeader {
451
+ const tokens = _scanDivs(html);
452
+ const linkToken = tokens.find(
453
+ (token) => !token.isEnd && _attr(token.attrs, 'data-type') === 'link' && _hasClass(token.attrs, 'thing'),
454
+ );
455
+ if (!linkToken) {
456
+ return { title: null, author: null, score: null, permalink: null };
457
+ }
458
+ const { attrs } = linkToken;
459
+ const region = html.slice(linkToken.end, linkToken.end + 20_000);
460
+
461
+ const titleMatch = /<a\b[^>]*\bclass\s*=\s*"[^"]*\btitle\b[^"]*"[^>]*>([\s\S]*?)<\/a>/i.exec(region);
462
+ const rawTitle = titleMatch?.[1] ?? null;
463
+ const title = rawTitle === null ? null : _clean(_htmlToText(rawTitle)) || null;
464
+
465
+ const rawScore = _attr(attrs, 'data-score');
466
+ let score: number | null = null;
467
+ if (rawScore !== null && /^-?\d+$/.test(rawScore.trim())) {
468
+ score = Number.parseInt(rawScore.trim(), 10);
469
+ } else {
470
+ score = _extractScore(region);
471
+ }
472
+
473
+ const permalinkRaw = _attr(attrs, 'data-permalink') ?? _attr(attrs, 'data-url');
474
+ const permalink = permalinkRaw === null ? null : _clean(permalinkRaw).trim() || null;
475
+
476
+ return { title, author: _normalizeAuthor(_attr(attrs, 'data-author')), score, permalink };
477
+ }
478
+
479
+ /**
480
+ * Login / interstitial detection — STRUCTURAL, and only ever consulted when the
481
+ * page yielded zero comment nodes. A real thread routinely links to `/login`
482
+ * (every vote arrow is `login-required`), so a marker alone must never flip
483
+ * this; the absence of any comment node is the load-bearing half.
484
+ */
485
+ function _detectLoginWall(html: string, commentCount: number): boolean {
486
+ if (commentCount > 0) {
487
+ return false;
488
+ }
489
+ const hasLoginForm = /<form\b[^>]*\b(?:id|class|action)\s*=\s*"[^"]*login[^"]*"/i.test(html);
490
+ const hasPasswordField =
491
+ /<input\b[^>]*\btype\s*=\s*"password"/i.test(html) || /<input\b[^>]*\bname\s*=\s*"passwd"/i.test(html);
492
+ const hasRefreshRedirect = /<meta\b[^>]*\bhttp-equiv\s*=\s*"refresh"/i.test(html);
493
+ const hasBlockedMarker = /\b(?:you must log in|log ?in to continue|whoa there, pardner|rate ?limit)\b/i.test(html);
494
+ return hasLoginForm || hasPasswordField || hasRefreshRedirect || hasBlockedMarker;
495
+ }
496
+
497
+ /**
498
+ * Parse a thread page. Pure: no I/O, no clock, no globals. Returns an empty
499
+ * `comments` array (never a fabricated one) when the page carries no comment
500
+ * nodes.
501
+ */
502
+ export function parseThread(html: string): ParsedThread {
503
+ const nodes = _collectComments(html);
504
+ const loginWall = _detectLoginWall(html, nodes.length);
505
+
506
+ if (loginWall) {
507
+ // A wall has no thread to report and no comments to invent.
508
+ return {
509
+ thread: { title: null, author: null, score: null, permalink: null },
510
+ comments: [],
511
+ login_wall: true,
512
+ };
513
+ }
514
+
515
+ const comments: ParsedComment[] = nodes.map((node) => {
516
+ const region = _ownRegion(html, node);
517
+ return {
518
+ id: _clean(node.id).trim(),
519
+ author: _normalizeAuthor(_attr(node.attrs, 'data-author')),
520
+ score: _extractScore(region),
521
+ depth: node.depth,
522
+ parent_id: node.parent_id === null ? null : _clean(node.parent_id).trim(),
523
+ body: _clean(_extractBody(region)),
524
+ };
525
+ });
526
+
527
+ return { thread: _extractThread(html), comments, login_wall: false };
528
+ }
529
+
530
+ function _readStdin(): Promise<string> {
531
+ return new Promise((resolve, reject) => {
532
+ const chunks: Buffer[] = [];
533
+ process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk));
534
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
535
+ process.stdin.on('error', reject);
536
+ });
537
+ }
538
+
539
+ const HELP = `reddit_thread_parse — deterministic old.reddit thread HTML → JSON
540
+
541
+ Usage:
542
+ curl -sSL -H 'User-Agent: <a real browser UA>' \\
543
+ 'https://old.reddit.com/r/<sub>/comments/<id>/' \\
544
+ | tsx src/scripts/_lib/reddit_thread_parse.ts
545
+
546
+ Reads HTML on stdin, writes one JSON object on stdout:
547
+ { thread: { title, author, score, permalink },
548
+ comments: [ { id, author, score, depth, parent_id, body } ],
549
+ login_wall: boolean }
550
+
551
+ Offline and deterministic: no request, no write, no clock. Fetching is the
552
+ caller's job, which is what makes the committed fixtures a real regression lock.
553
+ \`depth\` / \`parent_id\` come from container nesting, not a flat regex.
554
+ A hidden score is \`null\`, never \`0\`. Every string passes the sanitize floor
555
+ (bidi / zero-width / Unicode-Tag vectors stripped; visible text kept as DATA).
556
+
557
+ Exit codes: 0 parsed (incl. an empty or login-walled page) · 3 empty stdin.
558
+ `;
559
+
560
+ export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
561
+ if (argv.includes('--help') || argv.includes('-h')) {
562
+ process.stdout.write(HELP);
563
+ return 0;
564
+ }
565
+
566
+ const html = await _readStdin();
567
+ if (html.trim() === '') {
568
+ process.stderr.write('❌ reddit_thread_parse: no HTML on stdin (pipe a thread page in)\n');
569
+ return 3;
570
+ }
571
+
572
+ process.stdout.write(`${JSON.stringify(parseThread(html), null, 2)}\n`);
573
+ return 0;
574
+ }
575
+
576
+ function _isCliEntry(): boolean {
577
+ if (!process.argv[1]) {
578
+ return false;
579
+ }
580
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
581
+ if (import.meta.url === argvUrl) {
582
+ return true;
583
+ }
584
+ // A symlinked invocation makes the raw URLs differ (import.meta.url is the
585
+ // resolved real path while argv[1] keeps the symlink path) — compare
586
+ // realpaths so the CLI still fires through a projection symlink.
587
+ try {
588
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
589
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
590
+ return here === argv;
591
+ } catch {
592
+ return false;
593
+ }
594
+ }
595
+
596
+ if (_isCliEntry()) {
597
+ process.exit(await main());
598
+ }