super-ux 0.56.3 → 0.57.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,57 @@
1
+ ## 0.57.0 — short video gets a playbook, a hook filter and a caption linter
2
+
3
+ `copywriting` covered posts for X, Reddit and LinkedIn and nothing that moves:
4
+ no Reels, Shorts or TikTok physics, no hook, no script, no caption rules, and
5
+ an X playbook asserting two ranking rules the open ranker does not contain.
6
+ This release fills that from the family's 2026-10-07 research, every rule with
7
+ its source and the date it was read.
8
+
9
+ - **Three new references, linked from `copywriting`.** `video-script.md`: the
10
+ angle in one sentence, the modular hook, body and ask, six organic and ten
11
+ ad structures, words per second for English, the shot list, gates G2–G4,
12
+ reading the retention chart. **Russian timing is measured by reading aloud,
13
+ and the file carries no coefficient**: the research proposed one as its
14
+ author's assumption, and a table would have made it a rule nobody re-measures.
15
+ `hooks.md`: the mechanics (frame 0, text by about 0.5 s, works muted, the body
16
+ pays), the triple (frame 0, on-screen text of ten words or fewer, first line),
17
+ a taxonomy, 26 formulas and 40 templates, each template with its source.
18
+ `research-outliers.md`: views ÷ the account's median, 3× a signal, under 1.5×
19
+ noise, a sample within 10× of your size, with a worked example.
20
+ - **`channel-playbooks.md`.** New `Instagram` (the 125-character feed window,
21
+ five hashtags since 2025-12-18, links that do not click, one ask, sends as
22
+ the signal for non-followers) and `Short video` (platform signals, originality,
23
+ AI labelling, safe zones, length, pace, the loop, trial reels). **X is re-read
24
+ against `xai-org/x-algorithm@e62790c`**: the published weights (share via
25
+ copy link 20, reply, quote and share via DM 5, a reply from a mutual follow
26
+ +15, follow 4, like 0.5, report −234, mute −58.8) and the 48-hour window are
27
+ in; "bookmarks weigh heavily" and "more than two hashtags is penalised" are
28
+ out, because neither is in the ranker; link suppression stays as practice,
29
+ marked unverified. The seeded `channels.md` X record no longer carries them.
30
+ - **Four new codes, 42 → 46.** `B044` filters short-video hooks: five checks,
31
+ 0.6 × mean + 0.4 × minimum, −15 per dealbreaker, and on-screen text over ten
32
+ words. It is a filter, not a predictor, and its row says so in numbers (AUC
33
+ 0.83 at catching a bad hook, 0.56 at picking a winner); a hook in Cyrillic is
34
+ not scored against English vocabulary. `B045` and `B046` read two new
35
+ physics tokens, `fold N` and `one ask`. `B066` is the new marker AT-16:
36
+ invisible and look-alike-space characters, eighteen named classes plus any
37
+ other format character, seven numbered exemptions with a fixture each, and
38
+ it never examines a dash or a quotation mark. `B030` now reads the `hook` and
39
+ `on-screen` fields as public copy. `B042`'s message no longer assumes every
40
+ surface's reason is reach.
41
+ - **New surfaces:** `Instagram`, `TikTok`, `YouTube Shorts` and `short video`.
42
+ - **A gate for the shelf itself.** `validate_short_video_shelf` counts HF-01..26
43
+ and HT-01..40, refuses a template without a source URL, a short-video or
44
+ Instagram rule without one, the two removed X claims coming back, and
45
+ recomputes every multiple and reading in the outlier example. Each was
46
+ watched failing on a planted defect.
47
+ - Because `channel-playbooks.md` links the three new files, the closure ships
48
+ them inside `brand-voice` (13) and `ux-audit` (24) as well; their reference
49
+ sentences were recounted from the tree.
50
+ - Provenance: the 26 formula names, the scoring arithmetic, the dealbreakers,
51
+ the caption mechanics, the outlier method and the eighteen character classes
52
+ come from `Jakeschincariol/instagram-agent-skill` at `d03c56b` (MIT); shapes,
53
+ examples, word lists and code are rewritten and no text is reproduced.
54
+
1
55
  ## 0.56.3 — check the heading the reader sees
2
56
 
3
57
  The no-title-period rule already existed, but its linter read Markdown headings
package/README.md CHANGED
@@ -157,11 +157,12 @@ Commands: `/brand` (status → one recommended action), `/brand-init`,
157
157
  python3 docs/brand/lint.py
158
158
  ```
159
159
 
160
- 42 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
160
+ 46 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
161
161
  with no sourced fact, a field over its limit with the locale coefficient
162
162
  applied, blocked AI crawlers, keyword stuffing, humor on a billing screen,
163
163
  a rhetorical dash, a title that ends in a full stop, a locale that lags
164
- without saying so. Exit 0 clean **or warnings only**, 1 warnings under `--strict`, 2 any error. That is the policy `docs/ux/lint.py` has always had, and one pack cannot hold two opposite meanings for a warning: this linter returned 1 on warnings alone until 2026-08-20, so 13 of its 39 codes turned a build red while printing `0 error(s), 1 warning(s)`.
164
+ without saying so, a short-video hook the filter calls weak, a caption that
165
+ spends the feed window or asks twice, an invisible character pasted in. Exit 0 clean **or warnings only**, 1 warnings under `--strict`, 2 any error. That is the policy `docs/ux/lint.py` has always had, and one pack cannot hold two opposite meanings for a warning: this linter returned 1 on warnings alone until 2026-08-20, so 13 of its 39 codes turned a build red while printing `0 error(s), 1 warning(s)`.
165
166
 
166
167
  Clean means *checkable*, not *good*: tone drift, unproven claims and a voice
167
168
  that has overshot its own failure mode are judged by `/ux-audit copy`.
@@ -243,7 +244,7 @@ to is a skill nobody runs.
243
244
  | skill `ux-scenarios` | `docs/ux/scenarios.md`: use-case scenarios (action → observable response, alt and error paths) covering every flow node and edge, `Traces:` to stories and flows, validated for conflicts, coverage and traceability |
244
245
  | skill `ux-audit` | Batched audit with full context: code vs every scenario plus its story's acceptance criteria; verdicts PASS / PARTIAL / FAIL / BLOCKED with `file:line` evidence; depths `quick` / `standard` / `deep`; a `coverage` scope that audits the chain itself |
245
246
  | skill `brand-voice` | `docs/brand/`: the pack and its five axes, the words the product owns and bans, canonical facts, the per-surface register, locales, plus six shipped voice packs, each declaring the degeneration it collapses into when overdone |
246
- | skill `copywriting` | Writes in that voice and never writes *to* it: interface strings, errors, empty states, landing and pricing pages, posts, changelogs, store listings, ads, lifecycle email. A missing term or an unsourced number is reported, never invented |
247
+ | skill `copywriting` | Writes in that voice and never writes *to* it: interface strings, errors, empty states, landing and pricing pages, posts and captions, short-video scripts and hooks, changelogs, store listings, ads, lifecycle email. A missing term or an unsourced number is reported, never invented |
247
248
  | `/ux` | **The one command**: sets up whatever is missing, reports status across every layer, then offers only the applicable actions with one marked recommended. Idempotent |
248
249
  | `/vision` `/ux-init` `/ux-foundation` `/ux-flows` `/ux-update` `/ux-audit` `/ux-rule` `/ux-lint` `/ux-doctor` · `/brand` `/brand-init` `/brand-update` `/brand-lint` `/copy` | Direct controls for when you know exactly what you want; `/ux-rule` installs both hard rules and seeds `lint.py` + `doctor.py`; `/brand-init` seeds `docs/brand/` and its linter |
249
250
  | `docs/ux/lint.py` + `/ux-lint` | The deterministic half: missing Figma frames, unresolved SCR/story traces, orphans, built screens without coverage, index desync, ID gaps, broken links. Stdlib-only, exit 1 on problems, so wire it into CI and drift can't merge |
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: "Write or rewrite any text a user reads (interface strings, errors, empty states, landing and pricing pages, posts, changelogs, store listings, ads, email): apply when asked to write copy, rewrite a headline or CTA, fix microcopy, or when a draft reads machine-made"
2
+ description: "Write or rewrite any text a user reads (interface strings, errors, empty states, landing and pricing pages, posts and captions, short-video scripts and hooks, changelogs, store listings, ads, email): apply when asked to write copy, rewrite a headline or CTA, fix microcopy, or when a draft reads machine-made"
3
3
  alwaysApply: false
4
4
  ---
5
5
 
@@ -54,6 +54,17 @@ effortless / unlock / elevate / robust", em-dash cadence in every paragraph,
54
54
  "it's not just X, it's Y", hedges stacked two deep, and a closing sentence
55
55
  that summarizes what was just said.
56
56
 
57
+ ## Short video
58
+
59
+ A hook is three things at once, about one thing: frame 0, on-screen text of
60
+ ten words or fewer, and the first spoken line. The body pays the promise
61
+ before the middle. Write the angle first ("for [persona], who wants
62
+ [desire], we show [proof]"), time the script by reading it aloud (never
63
+ convert a word count into another language), and give every figure in the
64
+ hook its `facts.md` row. `B044` filters weak hooks; it is a filter, never a
65
+ predictor of which hook wins. A caption fits the feed window and makes one
66
+ ask (`B045`, `B046`).
67
+
57
68
  ## Localization
58
69
 
59
70
  A locale is not a translation of the English: the length coefficient, the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-ux",
3
- "version": "0.56.3",
3
+ "version": "0.57.0",
4
4
  "scripts": {
5
5
  "test": "python3 test/validate.py && python3 test/brand_lint_test.py && python3 test/display_copy_test.py && python3 test/ux_lint_test.py && python3 docs/ux/lint.py && python3 docs/brand/lint.py && node test/installer_test.js && npm run test:audit",
6
6
  "test:audit": "for t in test/audit_regressions/*.py; do python3 \"$t\" || exit 1; done"
@@ -81,7 +81,7 @@ STRING_KINDS = ("copy", "layout")
81
81
 
82
82
  SEVERITY_ERROR = "error"
83
83
  SEVERITY_WARN = "warn"
84
- WARNING_CODES = set("B003 B005 B007 B022 B025 B026 B031 B043 B051 B053 B054 B060 B063 B064 B071 B072".split())
84
+ WARNING_CODES = set("B003 B005 B007 B022 B025 B026 B031 B043 B044 B045 B046 B051 B053 B054 B060 B063 B064 B066 B071 B072".split())
85
85
 
86
86
  Finding = namedtuple("Finding", "code severity path line message")
87
87
 
@@ -1369,7 +1369,13 @@ def check_facts(brand_dir: Path, sources: dict) -> list[Finding]:
1369
1369
  f"`{row['fact']}` was due for review on {row['review']}",
1370
1370
  ))
1371
1371
 
1372
- for path, _fields, body in documents(brand_dir, sources, "marketing"):
1372
+ for path, fields, body in documents(brand_dir, sources, "marketing"):
1373
+ # A short-video hook is the first public sentence anyone hears, so its
1374
+ # figure needs a row exactly as a body figure does (video-script.md).
1375
+ spoken = [v for k, v in fields.items()
1376
+ if HOOK_FIELD_RE.match(k) or re.match(r"^on-screen(?:-\d+)?$", k)]
1377
+ if spoken:
1378
+ body = body + "\n\n" + "\n\n".join(spoken)
1373
1379
  for m in NUMBER_RE.finditer(body):
1374
1380
  number = m.group(0)
1375
1381
  digits = re.sub(r"\s+", "", number)
@@ -1461,9 +1467,23 @@ def check_channels(brand_dir: Path, sources: dict) -> list[Finding]:
1461
1467
  if "link in body" in physics and re.search(r"https?://", body):
1462
1468
  findings.append(Finding(
1463
1469
  "B042", SEVERITY_ERROR, path, 0,
1464
- "link in the post body -- this surface suppresses reach "
1465
- "for it; the convention is the first reply",
1470
+ "link in the post body, which this surface's physics "
1471
+ "forbid -- it suppresses reach or does not click there; "
1472
+ "put it where channels.md says (first reply, bio, DM)",
1466
1473
  ))
1474
+ fold = re.search(r"fold (\d+)", physics)
1475
+ if fold:
1476
+ findings.extend(_feed_window(path, body, int(fold.group(1))))
1477
+ if "one ask" in physics:
1478
+ asks = caption_asks(body)
1479
+ if len(asks) != 1:
1480
+ findings.append(Finding(
1481
+ "B046", SEVERITY_WARN, path, 0,
1482
+ (f"{len(asks)} asks ({', '.join(asks)}) -- two asks "
1483
+ f"split one reader's single action" if asks else
1484
+ "no ask -- decide what this post is for and ask for "
1485
+ "that one thing"),
1486
+ ))
1467
1487
  cap = re.search(r"max (\d+) hashtags", physics)
1468
1488
  if cap:
1469
1489
  used = re.findall(r"(?<!\w)#\w+", body)
@@ -1480,6 +1500,236 @@ def check_channels(brand_dir: Path, sources: dict) -> list[Finding]:
1480
1500
  return findings
1481
1501
 
1482
1502
 
1503
+ # B045 -- the feed window. Instagram shows roughly the first 125 characters of
1504
+ # a caption before "... more", so the first line either fits the window or is
1505
+ # cut mid-thought, and a tag or a mention spent there is a hook not written.
1506
+ # The window size is declared per surface (`fold N`) because it is platform
1507
+ # physics that moves, and the number belongs in channels.md, not in this file.
1508
+ # Method: `skills/ig-caption/caption.py` in Jakeschincariol/instagram-agent-skill
1509
+ # @d03c56b (MIT), re-implemented here; see channel-playbooks.md#instagram.
1510
+ HASHTAG_TOKEN_RE = re.compile(r"(?<![\w&])#\w+")
1511
+
1512
+
1513
+ def _feed_window(path: str, body: str, fold: int) -> list[Finding]:
1514
+ text = body.strip()
1515
+ if not text:
1516
+ return []
1517
+ first = text.splitlines()[0].strip()
1518
+ window = text[:fold]
1519
+ reasons = []
1520
+ if first[:1] in ("#", "@"):
1521
+ reasons.append(f"it opens with `{first.split()[0]}`")
1522
+ if HASHTAG_TOKEN_RE.search(window) and first[:1] != "#":
1523
+ reasons.append("a hashtag sits inside the window")
1524
+ if len(first) > fold:
1525
+ reasons.append(f"the first line is {len(first)} characters and the "
1526
+ f"feed cuts it at {fold}")
1527
+ if not reasons:
1528
+ return []
1529
+ shown = window.split("\n")[0] if len(first) > fold else first
1530
+ return [Finding(
1531
+ "B045", SEVERITY_WARN, path, 0,
1532
+ f"the feed window ({fold} characters before \"... more\"): "
1533
+ f"{'; '.join(reasons)}. The feed shows: \"{shown[:fold]}\"",
1534
+ )]
1535
+
1536
+
1537
+ # B046 -- one ask per post. Patterns are the asks a caption actually makes;
1538
+ # a question mark alone is not an ask, because most captions ask nothing by
1539
+ # asking a rhetorical question. English after caption.py's eight (re-written),
1540
+ # Russian as their direct equivalents.
1541
+ CAPTION_ASKS = (
1542
+ (re.compile(r"(?i)\bcomment (?:the word )?[\"']?[A-Z0-9]{2,}\b"), "comment a keyword"),
1543
+ (re.compile(r"(?i)\b(?:dm|message) (?:me|us)\b"), "DM"),
1544
+ (re.compile(r"(?i)\bsave (?:this|it)\b"), "save this"),
1545
+ (re.compile(r"(?i)\b(?:share|send) (?:this|it)\b"), "share this"),
1546
+ (re.compile(r"(?i)\bfollow (?:me|us|for)\b"), "follow"),
1547
+ (re.compile(r"(?i)\blink in (?:my |our |the )?bio\b"), "link in bio"),
1548
+ (re.compile(r"(?i)\b(?:swipe|tap) (?:through|left|right|for|to)\b"), "swipe or tap"),
1549
+ (re.compile(r"(?i)\btell me\b|\bwhat would you\b|\bwhich one\b"), "answer a question"),
1550
+ (re.compile(r"(?i)напиши(?:те)? в комментари|коммент(?:арий)? [А-ЯЁA-Z0-9]{2,}"), "comment a keyword"),
1551
+ (re.compile(r"(?i)напиши(?:те)? (?:мне|нам) в (?:директ|личку|лс)"), "DM"),
1552
+ (re.compile(r"(?i)\bсохрани(?:те)?\b"), "save this"),
1553
+ (re.compile(r"(?i)\b(?:отправь|отправьте|перешли|перешлите)\b"), "share this"),
1554
+ (re.compile(r"(?i)\bподпиши(?:сь|тесь)\b"), "follow"),
1555
+ (re.compile(r"(?i)ссылк[аиу] в (?:профиле|шапке|био)"), "link in bio"),
1556
+ )
1557
+
1558
+
1559
+ def caption_asks(body: str) -> list[str]:
1560
+ """The asks a caption makes, one entry per distinct kind."""
1561
+ return [name for pattern, name in CAPTION_ASKS if pattern.search(body)]
1562
+
1563
+
1564
+ # B044 -- the hook filter. A FILTER, never a predictor, and the reference says
1565
+ # so in numbers: on its author's corpus of 74 real short-form hooks the method
1566
+ # separates written-to-be-bad hooks from real ones at AUC 0.83 and a creator's
1567
+ # hits from the same creator's misses at AUC 0.56, where 0.50 is a coin. So
1568
+ # B044 warns on what it catches well (greetings, preambles, a line with
1569
+ # nothing concrete or nothing at stake) and never ranks two decent hooks.
1570
+ #
1571
+ # Method and arithmetic: `skills/ig-reel/hookscore.py` in
1572
+ # Jakeschincariol/instagram-agent-skill @d03c56b (MIT) -- five checks 0..100,
1573
+ # overall = 0.6 * mean + 0.4 * min - 15 per dealbreaker, STRONG at >= 70 with
1574
+ # no check under 55 and no dealbreaker, WEAK under 50. The vocabularies below
1575
+ # are this file's own and shorter, so a score can differ by a few points from
1576
+ # the original's. They are English, so a hook in another script is not scored
1577
+ # at all; only the two script-independent dealbreakers apply to it.
1578
+ HOOK_WORD_RE = re.compile(r"[A-Za-z0-9$%'’-]+")
1579
+ HOOK_FIGURE_RE = re.compile(
1580
+ r"[$€£]\s?\d[\d,]*(?:\.\d+)?"
1581
+ r"|\b\d[\d,]*(?:\.\d+)?\s?(?:%|k\b|x\b|hours?\b|hrs?\b|minutes?\b|mins?\b"
1582
+ r"|days?\b|weeks?\b|months?\b|years?\b)?",
1583
+ re.I,
1584
+ )
1585
+ HOOK_PROPER_RE = re.compile(r"(?<!^)\b[A-Z][a-z]{2,}\b")
1586
+ HOOK_SPOKEN_FIGURES = {
1587
+ "zero", "two", "three", "four", "five", "six", "seven", "eight", "nine",
1588
+ "ten", "twelve", "twenty", "thirty", "fifty", "hundred", "thousand",
1589
+ "million", "billion", "dozen", "half", "twice", "triple",
1590
+ "dollars", "dollar", "bucks", "grand", "percent", "revenue", "salary",
1591
+ "profit", "rent",
1592
+ }
1593
+ HOOK_STAKES = {
1594
+ "stop", "never", "wrong", "mistake", "mistakes", "lose", "lost", "losing",
1595
+ "cost", "costs", "broke", "failed", "fail", "nobody", "no", "not", "don't",
1596
+ "can't", "won't", "didn't", "quit", "fired", "deleted", "killed",
1597
+ "replaced", "cut", "free", "paid", "saved", "banned", "worst", "hate",
1598
+ "wasted", "waste", "scam", "lied", "truth", "secret", "hidden", "before",
1599
+ "until", "instead", "but", "unless", "problem", "risk", "warning",
1600
+ "regret", "only", "without", "versus", "vs", "actually", "forgot",
1601
+ }
1602
+ HOOK_WEAK_OPENERS = (
1603
+ "so", "ok", "okay", "hey", "hi", "hello", "guys", "welcome", "today",
1604
+ "basically", "honestly", "just", "let's", "lets", "um", "i wanted",
1605
+ "i want", "have you", "do you", "did you", "are you", "in this",
1606
+ "in today", "this is", "there is", "there are", "it is", "you know",
1607
+ )
1608
+ HOOK_IMPERATIVES = {
1609
+ "stop", "steal", "copy", "delete", "try", "watch", "read", "save", "use",
1610
+ "build", "make", "write", "send", "start", "quit", "never", "always",
1611
+ "don't", "check", "cancel",
1612
+ }
1613
+ HOOK_EMOJI_RE = re.compile("[\U0001F300-\U0001FAFF☀-➿]")
1614
+ HOOK_DEALBREAKERS = (
1615
+ ("stop-scrolling", re.compile(r"(?i)^\s*(?:stop scrolling|don'?t scroll)")),
1616
+ ("preamble", re.compile(r"(?i)\b(?:in (?:this|today'?s) (?:video|reel)"
1617
+ r"|i'?m going to show you|i'?ll show you how)\b")),
1618
+ ("greeting", re.compile(r"(?i)^\s*(?:hey|hi|hello|what'?s up|welcome)\b")),
1619
+ ("hashtag", re.compile(r"(?:^|\s)#\w+")),
1620
+ ("emoji", HOOK_EMOJI_RE),
1621
+ )
1622
+ # Applied to a hook in any script: neither depends on the vocabulary.
1623
+ SCRIPT_INDEPENDENT_DEALBREAKERS = ("hashtag", "emoji")
1624
+
1625
+
1626
+ def _hook_words(text: str) -> list[str]:
1627
+ return HOOK_WORD_RE.findall(re.sub(r"(?<=\d),(?=\d)", "", text))
1628
+
1629
+
1630
+ def hook_score(text: str) -> dict:
1631
+ """Score one spoken hook. `verdict` is None for a hook not in Latin script.
1632
+
1633
+ Returns {score, verdict, checks: {LENGTH, SPECIFICITY, STAKES, FRONTLOAD,
1634
+ ADDRESS}, flags: [dealbreaker ids]}.
1635
+ """
1636
+ flags = [name for name, pattern in HOOK_DEALBREAKERS if pattern.search(text)]
1637
+ if CYRILLIC_RE.search(text) or not re.search(r"[A-Za-z]", text):
1638
+ return {"score": None, "verdict": None, "checks": {},
1639
+ "flags": [f for f in flags if f in SCRIPT_INDEPENDENT_DEALBREAKERS]}
1640
+ words = _hook_words(text)
1641
+ low = [w.lower().strip("'’") for w in words]
1642
+
1643
+ n = len(words)
1644
+ length = 100.0 if 5 <= n <= 12 else (100 - (5 - n) * 20 if n < 5
1645
+ else 100 - (n - 12) * 11)
1646
+ if len(text.strip()) > 60: # two lines of large on-screen text
1647
+ length -= 12
1648
+
1649
+ figures = [m for m in HOOK_FIGURE_RE.findall(text) if m.strip()]
1650
+ hits = len(figures) + len(set(HOOK_PROPER_RE.findall(text))) \
1651
+ + sum(1 for w in low if w in HOOK_SPOKEN_FIGURES)
1652
+ specificity = 15.0 if hits == 0 else 45 + 30 * hits
1653
+
1654
+ markers = set(w for w in low if w in HOOK_STAKES)
1655
+ if re.search(r"[$€£]\s?\d", text):
1656
+ markers.add("a price")
1657
+ stakes = {0: 20.0, 1: 70.0}.get(len(markers), 100.0)
1658
+
1659
+ opener = " ".join(low[:2])
1660
+ penalty = 30 if any(opener.startswith(w) or (low and low[0] == w)
1661
+ for w in HOOK_WEAK_OPENERS) else 0
1662
+ payload = next((i for i, w in enumerate(low)
1663
+ if w in HOOK_STAKES or w in HOOK_SPOKEN_FIGURES
1664
+ or HOOK_FIGURE_RE.match(words[i])
1665
+ or (i and HOOK_PROPER_RE.match(words[i]))), None)
1666
+ frontload = (30.0 if payload is None else 100.0 if payload <= 3
1667
+ else 70.0 if payload <= 6 else 40.0) - penalty
1668
+
1669
+ lowered = text.lower()
1670
+ if re.search(r"\b(?:you|your|you're|yourself)\b", lowered):
1671
+ address = 100.0
1672
+ elif low and low[0] in HOOK_IMPERATIVES:
1673
+ address = 90.0
1674
+ elif re.search(r"\b(?:i|my|me|we|our)\b", lowered):
1675
+ address = 70.0
1676
+ else:
1677
+ address = 35.0
1678
+
1679
+ clamp = lambda v: max(0.0, min(100.0, float(v))) # noqa: E731
1680
+ checks = {"LENGTH": clamp(length), "SPECIFICITY": clamp(specificity),
1681
+ "STAKES": clamp(stakes), "FRONTLOAD": clamp(frontload),
1682
+ "ADDRESS": clamp(address)}
1683
+ parts = list(checks.values())
1684
+ score = clamp(0.6 * sum(parts) / len(parts) + 0.4 * min(parts)
1685
+ - 15 * len(flags))
1686
+ verdict = ("STRONG" if score >= 70 and min(parts) >= 55 and not flags
1687
+ else "OK" if score >= 50 else "WEAK")
1688
+ return {"score": score, "verdict": verdict, "checks": checks, "flags": flags}
1689
+
1690
+
1691
+ HOOK_FIELD_RE = re.compile(r"^hook(?:-\d+)?$")
1692
+ ON_SCREEN_LIMIT = 10
1693
+
1694
+
1695
+ def check_short_video(brand_dir: Path, sources: dict) -> list[Finding]:
1696
+ """B044 -- every `hook` / `hook-N` field, and its on-screen companion."""
1697
+ findings: list[Finding] = []
1698
+ for key in ("marketing", "store"):
1699
+ for path, fields, _body in documents(brand_dir, sources, key):
1700
+ for name in sorted(f for f in fields if HOOK_FIELD_RE.match(f)):
1701
+ hook = fields[name]
1702
+ if not hook:
1703
+ continue
1704
+ got = hook_score(hook)
1705
+ reasons = []
1706
+ if got["verdict"] == "WEAK":
1707
+ weakest = min(got["checks"], key=got["checks"].get)
1708
+ reasons.append(f"scores {got['score']:.0f} (WEAK); weakest "
1709
+ f"check {weakest} at "
1710
+ f"{got['checks'][weakest]:.0f}")
1711
+ if got["flags"]:
1712
+ reasons.append(f"dealbreaker(s): {', '.join(got['flags'])}")
1713
+ if reasons:
1714
+ findings.append(Finding(
1715
+ "B044", SEVERITY_WARN, path, 0,
1716
+ f"`{name}`: \"{hook}\" {'; '.join(reasons)}. A filter, "
1717
+ f"not a predictor: AUC 0.83 at catching a bad hook, "
1718
+ f"0.56 at picking a winner (hooks.md)",
1719
+ ))
1720
+ for name in sorted(f for f in fields
1721
+ if re.match(r"^on-screen(?:-\d+)?$", f)):
1722
+ words = fields[name].split()
1723
+ if len(words) > ON_SCREEN_LIMIT:
1724
+ findings.append(Finding(
1725
+ "B044", SEVERITY_WARN, path, 0,
1726
+ f"`{name}` is {len(words)} words; on-screen hook text "
1727
+ f"is read in about half a second, {ON_SCREEN_LIMIT} "
1728
+ f"words at most (hooks.md)",
1729
+ ))
1730
+ return findings
1731
+
1732
+
1483
1733
  def _ios_keywords(path: str, fields: dict, raw: str) -> list[Finding]:
1484
1734
  """B041 -- the four rules that recover a third of the 100-character field."""
1485
1735
  findings = []
@@ -1998,6 +2248,141 @@ def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
1998
2248
  return findings
1999
2249
 
2000
2250
 
2251
+ # AT-16 / B066 -- invisible and look-alike-space characters. They render as
2252
+ # nothing or as an ordinary space, survive every paste, and break search,
2253
+ # hyphenation and exact-match checks downstream. The eighteen named classes
2254
+ # are the ones the humanizer in Jakeschincariol/instagram-agent-skill @d03c56b
2255
+ # strips (`skills/ig-human/slop.json`); any other Unicode format character
2256
+ # (category Cf -- bidi embeddings, overrides and isolates among them) is
2257
+ # reported by the catch-all. This check REPORTS; it does not rewrite, because
2258
+ # every class below has a context where it is correct, and those contexts are
2259
+ # the numbered exemptions in ai-tells.md (AT-16-E1..E7).
2260
+ #
2261
+ # What it deliberately does NOT port from that humanizer: the typography pass
2262
+ # that turns an em dash into ", ", an en dash into "-" and curly quotes into
2263
+ # straight ones. In Russian the dash is normative and «ёлочки» are the
2264
+ # quotation marks; a rule that rewrote them would make correct text wrong.
2265
+ # The rhetorical dash is AT-06 / B062, which knows the difference.
2266
+ import unicodedata # noqa: E402
2267
+
2268
+ INVISIBLE_NAMED = {
2269
+ 0x200B: "zero-width", 0x200C: "zero-width", 0x200D: "zero-width",
2270
+ 0x2060: "zero-width", 0xFEFF: "zero-width", 0x00AD: "zero-width",
2271
+ 0x180E: "zero-width", 0x2063: "zero-width",
2272
+ 0x061C: "bidi", 0x200E: "bidi", 0x200F: "bidi",
2273
+ 0x00A0: "space", 0x202F: "space", 0x2009: "space", 0x2007: "space",
2274
+ 0x2003: "space", 0x2002: "space",
2275
+ }
2276
+ TAG_RANGE = range(0xE0000, 0xE0080)
2277
+ BIDI_ISOLATES = range(0x2066, 0x206A)
2278
+ RTL_RE = re.compile("[֐-ࣿיִ-﷿ﹰ-ﻼ]")
2279
+ # Locales whose own typography puts a no-break or narrow space where a writer
2280
+ # means one: «в пятницу», «1 000», « Prix : ». Short on purpose, like
2281
+ # COPULA_LOCALES: a wrong entry silences the space class for a whole language.
2282
+ TYPOGRAPHIC_SPACE_LOCALES = ("ru", "uk", "be", "fr")
2283
+
2284
+
2285
+ def _is_pictograph(char: str) -> bool:
2286
+ cp = ord(char)
2287
+ return (0x1F000 <= cp <= 0x1FAFF or 0x2600 <= cp <= 0x27BF
2288
+ or 0x2300 <= cp <= 0x23FF or cp == 0xFE0F)
2289
+
2290
+
2291
+ def _needs_joiner(char: str) -> bool:
2292
+ """A letter of a script that spells with ZWJ/ZWNJ (Arabic, Persian, Indic…)."""
2293
+ return 0x0590 <= ord(char) < 0x2000 and char.isalpha() \
2294
+ and not CYRILLIC_RE.match(char) and not ("Ͱ" <= char <= "Ͽ")
2295
+
2296
+
2297
+ def invisible_chars(text: str, locale: str | None = None) -> list[tuple]:
2298
+ """(index, code point, name, class) for every reportable character.
2299
+
2300
+ Exemptions, numbered in ai-tells.md: E1 a joiner inside an emoji sequence,
2301
+ E2 a joiner beside a letter of a script that needs it, E3 a direction mark
2302
+ or isolate in text that carries right-to-left letters, E4 tag characters
2303
+ inside a flag sequence, E5 typographic spaces in a locale whose typography
2304
+ uses them, E6 dashes and quotation marks (never examined), E7 a byte-order
2305
+ mark opening the file.
2306
+ """
2307
+ rtl = any(c.isalpha() for c in RTL_RE.findall(text)) # letters, not marks
2308
+ spaces_ok = bool(CYRILLIC_RE.search(text)) or (
2309
+ bool(locale) and locale.split("-")[0].lower() in TYPOGRAPHIC_SPACE_LOCALES)
2310
+ hits = []
2311
+ for i, char in enumerate(text):
2312
+ cp = ord(char)
2313
+ if cp in INVISIBLE_NAMED:
2314
+ cls = INVISIBLE_NAMED[cp]
2315
+ elif cp in TAG_RANGE:
2316
+ cls = "tag"
2317
+ elif unicodedata.category(char) == "Cf":
2318
+ cls = "format"
2319
+ else:
2320
+ continue
2321
+ prev = text[i - 1] if i else ""
2322
+ nxt = text[i + 1] if i + 1 < len(text) else ""
2323
+ if cp == 0xFEFF and i == 0: # E7
2324
+ continue
2325
+ if cp == 0x200D and prev and nxt and _is_pictograph(prev) \
2326
+ and _is_pictograph(nxt): # E1
2327
+ continue
2328
+ if cp in (0x200C, 0x200D) and ((prev and _needs_joiner(prev))
2329
+ or (nxt and _needs_joiner(nxt))): # E2
2330
+ continue
2331
+ if rtl and (cls == "bidi" or cp in BIDI_ISOLATES): # E3
2332
+ continue
2333
+ if cls == "tag": # E4
2334
+ start = i
2335
+ while start and ord(text[start - 1]) in TAG_RANGE:
2336
+ start -= 1
2337
+ end = i
2338
+ while end + 1 < len(text) and ord(text[end + 1]) in TAG_RANGE:
2339
+ end += 1
2340
+ if start and text[start - 1] == "\U0001F3F4" and ord(text[end]) == 0xE007F:
2341
+ continue
2342
+ if cls == "space" and spaces_ok: # E5
2343
+ continue
2344
+ name = unicodedata.name(char, f"U+{cp:04X}")
2345
+ hits.append((i, cp, name, cls))
2346
+ return hits
2347
+
2348
+
2349
+ def check_invisible(brand_dir: Path, sources: dict) -> list[Finding]:
2350
+ """B066 -- AT-16 over every declared copy source, read as raw text.
2351
+
2352
+ Raw on purpose: a literal character is what travels into a paste, while
2353
+ an HTML entity such as `&nbsp;` is markup someone typed deliberately and
2354
+ is not reported. One finding per file and class, at the first line.
2355
+ """
2356
+ findings: list[Finding] = []
2357
+ primary, _ = declared_locales(brand_dir)
2358
+ root = brand_dir.parent.parent
2359
+ seen: set = set()
2360
+ for key in ("ui", "marketing", "store", "locales"):
2361
+ for pattern in sources.get(key, []):
2362
+ for path in source_paths(root, pattern):
2363
+ if path in seen or not path.is_file():
2364
+ continue
2365
+ seen.add(path)
2366
+ text = read(path) or ""
2367
+ fields, _ = _front_matter(text)
2368
+ hits = invisible_chars(text, fields.get("locale") or primary)
2369
+ grouped: dict = {}
2370
+ for index, cp, name, cls in hits:
2371
+ grouped.setdefault((cls, cp, name), []).append(index)
2372
+ rel = path.relative_to(root).as_posix()
2373
+ for (cls, cp, name), where in sorted(grouped.items(),
2374
+ key=lambda kv: kv[1][0]):
2375
+ line = text.count("\n", 0, where[0]) + 1
2376
+ findings.append(Finding(
2377
+ "B066", SEVERITY_WARN, rel, line,
2378
+ f"U+{cp:04X} {name} ({cls} class) x{len(where)} -- "
2379
+ f"invisible in the editor and carried into every "
2380
+ f"paste. Remove it, or keep it where ai-tells.md "
2381
+ f"AT-16 names the context that needs it",
2382
+ ))
2383
+ return findings
2384
+
2385
+
2001
2386
  def declared_locales(brand_dir: Path) -> tuple[str | None, list[str]]:
2002
2387
  """(primary, others) from voice.md's `Locales:` line."""
2003
2388
  text = read(brand_dir / "voice.md") or ""
@@ -2164,8 +2549,10 @@ def run(brand_dir: Path, fix: bool = False) -> list[Finding]:
2164
2549
  findings.extend(check_consistency(brand_dir, sources))
2165
2550
  findings.extend(check_facts(brand_dir, sources))
2166
2551
  findings.extend(check_channels(brand_dir, sources))
2552
+ findings.extend(check_short_video(brand_dir, sources))
2167
2553
  findings.extend(check_bot_safety(brand_dir, sources))
2168
2554
  findings.extend(check_ai_tells(brand_dir, sources))
2555
+ findings.extend(check_invisible(brand_dir, sources))
2169
2556
  findings.extend(check_locales(brand_dir, sources))
2170
2557
  return findings
2171
2558
 
@@ -103,14 +103,36 @@ Locales: <coefficient applies to both limits>
103
103
  Register: density +1, distance -1
104
104
  Format: one idea; threads 5-12 posts, each standing alone
105
105
  Limits: 280
106
- Forbidden: physics: link in body suppresses reach; >2 hashtags penalised;
107
- editing within 30 minutes resets distribution
108
- | brand: <engagement bait, fake urgency>
106
+ Forbidden: physics: link in body (practice; unverified in the open ranker) | brand: <engagement bait, fake urgency>
109
107
  CTA: <in the first reply, not the post>
110
108
  Proof: <one figure, sourced>
111
109
  Locales: <…>
112
110
  ```
113
111
 
112
+ ### Instagram
113
+
114
+ ```
115
+ Register: distance -1
116
+ Format: a caption; the first line is the window the feed shows
117
+ Limits: body 2200
118
+ Forbidden: physics: link in body, max 5 hashtags, fold 125, one ask | brand: <engagement bait>
119
+ CTA: one, spoken in the video as well
120
+ Proof: <one figure, sourced>
121
+ Locales: <…>
122
+ ```
123
+
124
+ ### short video
125
+
126
+ ```
127
+ Register: distance -1, density +1
128
+ Format: hook triple (frame 0, on-screen text, first line), body, one ask
129
+ Limits: <running time; Russian and other locales timed by reading aloud>
130
+ Forbidden: physics: none | brand: <fabricated testimonials, unlabelled synthetic people>
131
+ CTA: one, or a loop back to the first line
132
+ Proof: <one figure, sourced>
133
+ Locales: <…>
134
+ ```
135
+
114
136
  ### Reddit
115
137
 
116
138
  ```
@@ -125,5 +147,5 @@ Locales: <…>
125
147
  ```
126
148
 
127
149
  <add the remaining marketing surfaces this product uses: landing body,
128
- pricing, blog, changelog, LinkedIn, HN and Product Hunt, App Store,
129
- Google Play, ads, lifecycle email>
150
+ pricing, blog, changelog, TikTok, YouTube Shorts, LinkedIn, HN and Product
151
+ Hunt, App Store, Google Play, ads, lifecycle email>