super-ux 0.49.1 → 0.52.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,5 +1,154 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.52.0 — the dash rule stops checking the glyph, humanization stops being a mode, and the pack learns to assemble a product
4
+
5
+ - **`B062` judges the dash's role rather than its codepoint.** `DASH` was the single
6
+ character `—`, so a find-and-replace swapping it for `–` or for a hyphen with a space
7
+ each side cleared every finding and left the habit untouched. Measured in the wild on
8
+ trycomp.ai, 2026-08-30: twenty rhetorical dashes on one page and not one em dash among
9
+ them. `normalise_dash_spelling` now reduces every spelling to the canonical mark before
10
+ the three existing branches judge it, so the conjunction rule, the paired-dash rule,
11
+ the locale allowances and the range and direct-speech exemptions all apply unchanged to
12
+ all three spellings. The substitutions are length-preserving by construction and the
13
+ finding quotes the raw text, so a report shows the characters the author actually typed
14
+ rather than a mark they never used.
15
+ - **A dash alone in a table cell is an empty string, and now the code agrees.** `AT-06`
16
+ has listed the no-value cell dash among the grammatical exemptions since it was
17
+ written, and nothing implemented it: in any strict locale `| landing | — |` was
18
+ reported. `TABLE_CELL_DASH_RE` blanks it before judgement. The doctrine is corrected in
19
+ the same change, because it opened "The em dash is banned where it is rhetorical" while
20
+ the rule it states is about the mark's role.
21
+ - **`landing-pages.md`, a new reference in `copywriting`: how a landing page is
22
+ assembled, not how its sentences are edited.** Twenty rules with ids `LP-01..LP-20`
23
+ across five layers — the offer, awareness and the shape it demands, the proof ladder,
24
+ the action and the risk beside it, and the page as a machine — each carrying a verbatim
25
+ example from a page that shipped. It closes six gaps the pack had: no offer
26
+ architecture, no awareness-to-structure map, no proof ladder, no CTA or risk mechanics,
27
+ no answer for a category nobody searches for yet, and no readiness criterion. The file
28
+ ends in a runnable readiness check and says which of its rules no command can decide.
29
+ - **`validate_landing_coverage` is the answer to "what would notice if this fell
30
+ behind?"** Table rows against sections in both directions, duplicate ids, a gap in the
31
+ sequence, an id the readiness check names that no section defines, and the
32
+ `copywriting/SKILL.md` link without which the reference ships to nobody. Four defects
33
+ planted, each caught by its own message and reverted. The validator grows 4174 → 4197
34
+ checks, `brand_lint_test.py` 89 → 93, and `test/floors.json` ratchets with both.
35
+ - **The evidence is in the repository, not in a summary of it.** `docs/research/landings/`
36
+ carries the three teardowns the rules were extracted from — crowdreply.io, trycomp.ai
37
+ and zerorank.ai, read 2026-08-30 as raw markup, rendered text and in a browser. Three
38
+ independent pages committed the same four defects, and those four are the ones stated
39
+ as classes: answers absent from the markup, one action under several labels, numbers
40
+ that disagree with themselves, and the strongest proof filed one click behind the claim
41
+ it proves.
42
+
43
+ ## 0.50.0 — the templates ship where the texts say they are
44
+
45
+ - **`templates/` now travels with everything that names it** (SUX-01, family audit
46
+ 2026-08-29). Six shipped texts pointed at "the plugin's `templates/`" while the
47
+ marketplace ships `./plugins/super-ux` and the directory lived at the repo root only —
48
+ verified absent from all 13 cached installed versions, so `/brand-init`, `/brand`,
49
+ `/ux-rule` step 2 and the three seeding skills dead-ended at a path that resolves in
50
+ the one place users never run from: this repository's own checkout. The repo root stays
51
+ the single source (the hard rules and the installer CLI read it);
52
+ `test/sync_references.py` now mirrors the full tree into `plugins/super-ux/templates/`
53
+ and the named seeds into each seeding skill's own directory — a skill installed by the
54
+ skills CLI has no plugin root to reach up to — and the three skill texts say "this
55
+ skill's own `templates/…`" while the three command texts keep "the plugin's
56
+ `templates/…`", which is now true.
57
+ - **The class is gated, not just the instance.** `validate_shipped_templates` refuses a
58
+ copy that drifts from its source and a copy with no source, in both homes;
59
+ `validate_shipped_paths` requires every backticked `templates/…` or `scripts/…` token
60
+ in a shipped text to resolve inside what actually ships — the plugin root for commands,
61
+ the skill's own directory for skill files. Six defects planted, each caught by its own
62
+ branch with its own message, each reverted; the sync verified idempotent across three
63
+ runs. The validator grows 4111 → 4174 checks and `test/floors.json` ratchets with it.
64
+ - **`/ux-audit` admits its whole scope surface** (SUX-06). `copy` and
65
+ `benchmark:<competitor>` join the `argument-hint` and the step-1 enumeration — the body
66
+ has treated both as legal scopes since they shipped, while the two places an agent
67
+ reads first omitted them.
68
+ - **Trigger hygiene in three descriptions** (SUX-07, SUX-08, SUX-11). `ux-scenarios`
69
+ defers the empty-project start up the chain — vision and ux-foundation own it — instead
70
+ of claiming "ANY new feature or project" unqualified; `copywriting` narrows "build a
71
+ landing page / сделай лендинг" to the copy for it, naming sheleg-design as the visual
72
+ half; `ux-flows` drops the bare "figma"/"фигма" claim and delegates the visual system
73
+ and Figma variables to sheleg-design, mirroring its own body. Every phrase the family
74
+ umbrella routes on remains a literal substring of its description — verified with the
75
+ umbrella's own `advertised_check.js` (43/43, and watched failing against a dropped
76
+ «мокап»).
77
+ - **Humanization runs by default, in every mode that produces text.** It was a mode you
78
+ had to know to ask for, so the common path produced unswept drafts, and the one field
79
+ that touched the question -- `Humanization pass:` -- existed **only in the template**:
80
+ absent from `brand-contract.md`, absent from this pack's own `voice.md`, and read by no
81
+ code anywhere. Two fields now answer the two different questions the old one conflated.
82
+ `Humanization: on | off` is whether the pass runs, defaulting to `on` when absent;
83
+ `Humanization pass:` names which implementation, defaulting to `own`. Write, Edit and
84
+ Adapt each end in the sweep, positioned where it cannot be wasted: after the seven
85
+ sweeps in Edit, per surface in Adapt. `Humanize` survives as the standalone mode for
86
+ auditing text nobody is writing.
87
+ - **The state is visible in four places, because a pass that runs invisibly is
88
+ indistinguishable from one that did not.** `voice.md` records it, every delivery of copy
89
+ prints a status line naming the pass and what it changed, `/ux` and `/brand` report it
90
+ in their status blocks, and `B064` refuses the three states that are defects: an absent
91
+ field warns that the default applies unrecorded, an out-of-enum value errors, and `off`
92
+ with no `Humanization declined:` reason errors. The enum joins
93
+ `validate_status_enums_match_contract` from the day it was written rather than after it
94
+ drifted, which required generalising `DOC_ENUM_DECL_RE` past the hardcoded `**Status**`
95
+ so the next document-level enum is compared too.
96
+ - **`header_field` stops swallowing an aligned comment.** Standing instruction #3 fired on
97
+ the first enum field the templates seed with a literal value beside a comment: the
98
+ freshly seeded pack errored because the whole line, `# on | off; on is the default`
99
+ included, was the value. Two spaces or more before a `#` now ends the value; one space
100
+ does not, so a `Humanization declined: per ticket #431` keeps its reason.
101
+ - **Three references give the pack the assembly layer above its 241 tactics.**
102
+ `onboarding.md` (`ON-01..ON-18`, in `ux-flows`) orders the path to the first value and
103
+ rests on that value being defined first. `internal-screens.md` (`IS-01..IS-18`, in
104
+ `ux-flows`) covers the screens nobody A/B tests, where the four states are one design
105
+ and a list is a working surface rather than a directory of links.
106
+ `product-frameworks.md` (`PF-01..PF-12`, in `ux-foundation`) carries the named decision
107
+ models the pack had **none** of -- measured: Hook, Fogg, Kano, opportunity solution
108
+ tree, AARRR, north star, time to value, value proposition canvas, forces of progress,
109
+ switch interview, service blueprint and double diamond all returned zero occurrences --
110
+ each with the failure mode that makes it worth knowing rather than worth quoting.
111
+ - **One coverage gate over four id sets, not four copies of one.**
112
+ `validate_landing_coverage` became `validate_doctrine_set_coverage`, parameterised over
113
+ `LP`, `ON`, `IS` and `PF`: rows against sections both ways, duplicates, sequence gaps,
114
+ an id the readiness check names that no section defines, and the `SKILL.md` link without
115
+ which a reference ships to nobody. A fourth hand-written copy of one comparison is the
116
+ drift the gate exists to refuse.
117
+ - **The board went to zero.** All eleven open rows closed with a mechanism and a watched
118
+ plant each, not with a status change. `B-029` got the decision it asked for rather than a
119
+ filter: `strings.md` gains `Kind: copy | layout`, so an aligned option table is registered
120
+ and exempt from the rules that would be judging typesetting, and `docs/brand/lint.py` now
121
+ prints `brand pack is clean` where it printed a permanent warning. `B-028`: a `Coverage:`
122
+ citation may name its subject (`path:start-end symbolName`) and `U078` resolves it, catching
123
+ the drift a range cannot report about itself. `B-023`: `B005` asks `git log -L` about the
124
+ cited entries rather than the whole file, so a jobs edit stops warning about personas.
125
+ `B-005`: `U076` says a vision is still the seeded template instead of passing until someone
126
+ self-declares `approved`. `B-001`: the seeded linter carries `VISION_RULE_TEXT`, `U077` warns
127
+ when a target project's installed rule differs, and `validate_vision_rule_embed` keeps that
128
+ third copy honest. `B-030` and `B-031` get contract-parity gates; `B-032` gets `test/evals/`
129
+ with four cases whose anchors must still resolve.
130
+ - **Two of the eleven turned out not to be what the board said.** `B-021` asked for a
131
+ reachability arrow that had existed inside `validate_bp_index` since before the row was
132
+ filed; a duplicate check was written, caught by planting `BP-242`, and deleted rather than
133
+ shipped. What was genuinely missing ran the other way: a routing row pointing at a `BP-NNN`
134
+ the catalog does not define, invisible because every check in that function starts from the
135
+ catalog. `B-019` did not reproduce in four probes; it had been fixed by an uncredited
136
+ refactor, and the class is now mechanical — the report prints each distinct message once and
137
+ names any duplicate emission.
138
+ - **The refreshed code graph asserted a number nobody computed.** `B-022`'s refresh ran
139
+ unattended (1149 nodes, 1801 edges, built from `6348b641`), and 58 label fields said
140
+ `82 tags, 206 practices` about a catalog of 241 and an index that states no counts at all —
141
+ cached across refreshes, and read with the authority of a machine. Corrected, and gated by
142
+ `validate_graph_claims`, narrowed to `label`/`norm_label` because a node summarising a past
143
+ defect legitimately quotes an old number, and one of them does.
144
+ - **The bytecode cache could defeat a planted defect, which is this project's unit of
145
+ evidence.** CPython invalidates on `(mtime, size)`, so swapping `"B064"` for `"B999"` --
146
+ identical length -- and reverting inside the same second left a `.pyc` the interpreter
147
+ considered current: the revert ran the plant and the transcript reported a defect no
148
+ longer in the file. Observed live during this run's own plants. Both harnesses now set
149
+ `sys.dont_write_bytecode`, verified by plant-then-revert-in-second going red then green
150
+ with no cache clear.
151
+
3
152
  ## 0.49.1 — the skills handoff refuses the shadow it used to create
4
153
 
5
154
  - **The skills-menu item now consults the target home before delegating.** `npx skills add`
package/README.md CHANGED
@@ -157,7 +157,7 @@ Commands: `/brand` (status → one recommended action), `/brand-init`,
157
157
  python3 docs/brand/lint.py
158
158
  ```
159
159
 
160
- 39 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
160
+ 41 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
@@ -248,7 +248,7 @@ to is a skill nobody runs.
248
248
  | `/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
249
  | `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 |
250
250
  | `cursor/rules/*.mdc` | The same methodology for Cursor: one always-on hard rule + seven agent-requested rules (vision, foundation, flows, scenarios, audit, brand voice, copywriting) |
251
- | `templates/` | Seeds for `docs/ux/`: the vision skeleton, foundation, flows, screens, scenario base, the folder README, and the audit-report skeleton. Both hard-rule snippets live here as their single source, `claude-rule.md` (scenario-first) and `vision-rule.md` (vision alignment), and the validator fails if a command's embedded copy drifts from them. Seeds for `docs/brand/`: voice, terminology, facts, channels, the string registry, a locale delta, and its folder README |
251
+ | `templates/` | Seeds for `docs/ux/`: the vision skeleton, foundation, flows, screens, scenario base, the folder README, and the audit-report skeleton. Both hard-rule snippets live here as their single source, `claude-rule.md` (scenario-first) and `vision-rule.md` (vision alignment), and the validator fails if a command's embedded copy drifts from them. Seeds for `docs/brand/`: voice, terminology, facts, channels, the string registry, a locale delta, and its folder README. The tree is mirrored into `plugins/super-ux/templates/` (and the seeds each skill names into that skill's own directory) by `test/sync_references.py`, so installed channels carry the seeds their texts point at; the validator refuses drift between the copies |
252
252
 
253
253
  The contracts every skill reads:
254
254
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-ux",
3
- "version": "0.49.1",
3
+ "version": "0.52.0",
4
4
  "scripts": {
5
5
  "test": "python3 test/validate.py && python3 test/brand_lint_test.py && python3 test/ux_lint_test.py && python3 docs/ux/lint.py && python3 docs/brand/lint.py && node test/installer_test.js"
6
6
  },
@@ -56,6 +56,24 @@ CONTRACT_VERSION = "v1"
56
56
  # out-of-enum value that is neither refused nor accepted, and invisible.
57
57
  VOICE_STATUSES = ("draft", "validated")
58
58
 
59
+ # Whether the humanization pass runs at all, which is a different question from
60
+ # `Humanization pass:`, which names WHICH implementation runs. `on` is the
61
+ # pack's default and it is a default rather than a preference: an unswept draft
62
+ # carries the markers `ai-tells.md` grades, and a reader registers them before
63
+ # they can name why. Turning it off is a legitimate decision and it IS a
64
+ # decision, so it leaves its reason in the file instead of a silence that reads
65
+ # identically to never having been asked.
66
+ HUMANIZATION_MODES = ("on", "off")
67
+
68
+ # What a registry row IS, which decides which rules may judge it. `copy` is
69
+ # language and every language rule applies. `layout` is a column-aligned table,
70
+ # an ASCII frame, a banner: a string whose shape carries the meaning, where a
71
+ # capitalisation or dash rule would be judging typesetting. It is registered
72
+ # rather than exempted from registration, so `B022` still knows it exists and
73
+ # nobody can hide a real string by calling it furniture. Absent, a row is
74
+ # `copy`, because the safe default is to check.
75
+ STRING_KINDS = ("copy", "layout")
76
+
59
77
  SEVERITY_ERROR = "error"
60
78
  SEVERITY_WARN = "warn"
61
79
 
@@ -122,10 +140,74 @@ def content_date(path: Path) -> str | None:
122
140
  return None
123
141
 
124
142
 
143
+ # A trailing comment, aligned away from the value the way the seeded templates
144
+ # write one. Two spaces or more, because one space is prose: a `Humanization
145
+ # declined:` reason may legitimately say "per ticket #431", and truncating it
146
+ # would read as no reason at all. Found by standing instruction #3 on the first
147
+ # enum field the templates seeded with a literal value beside a comment.
148
+ HEADER_COMMENT_RE = re.compile(r"[ \t]{2,}#.*$")
149
+
150
+
151
+ def entry_line_span(text: str, ident: str) -> tuple[int, int] | None:
152
+ """The 1-based line span of `### <ident>:` up to the next entry or section."""
153
+ lines = text.split("\n")
154
+ start = next((i for i, l in enumerate(lines, 1)
155
+ if l.startswith(f"### {ident}:")), None)
156
+ if start is None:
157
+ return None
158
+ for j in range(start, len(lines)):
159
+ if lines[j].startswith("### ") or lines[j].startswith("## "):
160
+ return start, j
161
+ return start, len(lines)
162
+
163
+
164
+ def cited_entries_date(path: Path, idents: list[str]) -> tuple[str | None, bool]:
165
+ """When the CITED entries last changed, not when the file did.
166
+
167
+ `B-023`: `B005` asked whether `foundation.md` had moved since the voice was
168
+ calibrated, and every entry in it shares one file date, so editing the
169
+ monetization section raised a warning about personas nobody had touched.
170
+ Measured on SU-02: seven story entries changed, zero changed lines
171
+ mentioned any cited id, and the gate went red with a re-stamp as the only
172
+ way out. A warning that fires when nothing it protects moved is one people
173
+ learn to re-stamp past.
174
+
175
+ Returns `(date, exact)`. `exact` is False when the per-entry question could
176
+ not be answered -- no git, an untracked file, a renamed entry -- and the
177
+ whole-file date was used instead, which is the old behaviour. The caller
178
+ says which it got, because a narrowed check that silently widens is worse
179
+ than one that never narrowed.
180
+ """
181
+ text = read(path) or ""
182
+ spans = [entry_line_span(text, i) for i in idents]
183
+ if not spans or any(sp is None for sp in spans):
184
+ return content_date(path), False
185
+ import subprocess
186
+
187
+ dates: list[str] = []
188
+ for span in spans:
189
+ assert span is not None
190
+ try:
191
+ out = subprocess.run(
192
+ ["git", "log", "-L", f"{span[0]},{span[1]}:{path.name}",
193
+ "--format=%cs", "-s", "-1"],
194
+ cwd=path.parent, capture_output=True, text=True, timeout=15,
195
+ )
196
+ except (OSError, subprocess.SubprocessError):
197
+ return content_date(path), False
198
+ stamp = out.stdout.strip().split("\n")[0].strip()
199
+ if out.returncode != 0 or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", stamp):
200
+ return content_date(path), False
201
+ dates.append(stamp)
202
+ return (max(dates) if dates else None), True
203
+
204
+
125
205
  def header_field(text: str, key: str) -> str | None:
126
- """A `Key: value` line from a file's header block."""
206
+ """A `Key: value` line from a file's header block, comment stripped."""
127
207
  match = re.search(rf"^{re.escape(key)}:\s*(.+?)\s*$", text, re.M)
128
- return match.group(1) if match else None
208
+ if not match:
209
+ return None
210
+ return HEADER_COMMENT_RE.sub("", match.group(1)).strip() or None
129
211
 
130
212
 
131
213
  def table_rows(text: str) -> list[list[str]]:
@@ -218,8 +300,39 @@ def check_contract(brand_dir: Path) -> list[Finding]:
218
300
  f"check tests for the value instead of against `draft`",
219
301
  ))
220
302
 
303
+ # B064 -- the humanization pass: does it run, and was that decided.
304
+ # Three branches, one code, and each is reachable only in the state the
305
+ # others are not: absent, present-and-illegal, present-legal-and-off. A
306
+ # fixture for one therefore cannot be satisfied by another firing.
307
+ humanization = header_field(voice, "Humanization")
308
+ if humanization is None:
309
+ findings.append(Finding(
310
+ "B064", SEVERITY_WARN, "voice.md", 1,
311
+ f"no `Humanization:` field, so the pack default `on` applies and "
312
+ f"nobody has recorded whether that was chosen -- write "
313
+ f"`Humanization: on` to make the state readable, here and in every "
314
+ f"status this pack prints",
315
+ ))
316
+ elif not unfilled(humanization):
317
+ if humanization not in HUMANIZATION_MODES:
318
+ findings.append(Finding(
319
+ "B064", SEVERITY_ERROR, "voice.md", 1,
320
+ f"`Humanization: {humanization}` is not one of "
321
+ f"{' | '.join(HUMANIZATION_MODES)} -- an unrecognised value "
322
+ f"leaves the pass in neither state, and the copy modes read "
323
+ f"this field to decide whether to run",
324
+ ))
325
+ elif humanization == "off" and not header_field(voice, "Humanization declined"):
326
+ findings.append(Finding(
327
+ "B064", SEVERITY_ERROR, "voice.md", 1,
328
+ f"`Humanization: off` with no `Humanization declined:` line -- "
329
+ f"switching the pass off is a decision and it outlives whoever "
330
+ f"made it, so it carries the reason and the date rather than "
331
+ f"reading, later, as an oversight nobody can safely reverse",
332
+ ))
333
+
221
334
  strings = read(brand_dir / "strings.md") or ""
222
- agreed = [r for r in table_rows(strings) if r and r[-1] == "agreed"]
335
+ agreed = [r for r in registry(brand_dir) if r["status"] == "agreed"]
223
336
  if status == "draft" and agreed:
224
337
  findings.append(Finding(
225
338
  "B003", SEVERITY_WARN, "voice.md", 1,
@@ -243,12 +356,19 @@ def check_contract(brand_dir: Path) -> list[Finding]:
243
356
  calibrated = header_field(voice, "Last calibrated")
244
357
  if foundation is not None and calibrated:
245
358
  try:
246
- changed = content_date(brand_dir.parent / "ux" / "foundation.md")
359
+ changed, exact = cited_entries_date(
360
+ brand_dir.parent / "ux" / "foundation.md", ids
361
+ )
247
362
  if changed and changed > calibrated:
363
+ what = (f"the entries this voice cites ({', '.join(ids)}) "
364
+ f"changed" if exact else
365
+ "foundation.md changed, and the per-entry question "
366
+ "could not be answered here so the whole file was "
367
+ "used")
248
368
  findings.append(Finding(
249
369
  "B005", SEVERITY_WARN, "voice.md", 1,
250
- f"foundation.md changed on {changed}, after the voice "
251
- f"was last calibrated on {calibrated}",
370
+ f"{what} on {changed}, after the voice was last "
371
+ f"calibrated on {calibrated}",
252
372
  ))
253
373
  except OSError:
254
374
  pass
@@ -303,6 +423,10 @@ def registry(brand_dir: Path) -> list[dict]:
303
423
  rows.append({
304
424
  "key": cells[0], "text": cells[1], "location": cells[2],
305
425
  "scenario": cells[3], "status": cells[4],
426
+ # Sixth column, appended so a five-column registry written before
427
+ # this existed keeps every index it had. Absent means `copy`.
428
+ "kind": (cells[5].strip() if len(cells) > 5 and cells[5].strip()
429
+ else "copy"),
306
430
  })
307
431
  return rows
308
432
 
@@ -358,6 +482,16 @@ def check_terminology(brand_dir: Path) -> list[Finding]:
358
482
  findings: list[Finding] = []
359
483
  banned, terms, entities = dictionary(brand_dir)
360
484
  for row in registry(brand_dir):
485
+ if row["kind"] not in STRING_KINDS:
486
+ findings.append(Finding(
487
+ "B065", SEVERITY_ERROR, "strings.md", 0,
488
+ f"`{row['key']}` has `Kind: {row['kind']}`, which is not one of "
489
+ f"{' | '.join(STRING_KINDS)} -- an unrecognised kind is treated "
490
+ f"as `copy` and judged by every language rule, so the row is "
491
+ f"neither exempt nor knowingly checked",
492
+ ))
493
+ if row["kind"] == "layout":
494
+ continue
361
495
  text = row["text"]
362
496
  for word in banned:
363
497
  if _mentions(word, text):
@@ -575,6 +709,8 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
575
709
  for row in rows:
576
710
  if not LABEL_KEY_RE.match(row["key"]):
577
711
  continue
712
+ if row["kind"] == "layout":
713
+ continue # a terminal full stop inside a frame is not a title
578
714
  text = row["text"].rstrip()
579
715
  if not text.endswith(".") or text.endswith("..") or text.endswith("…"):
580
716
  continue
@@ -649,6 +785,8 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
649
785
  for name in entity_words:
650
786
  proper.update(name.split())
651
787
  for row in rows:
788
+ if row["kind"] == "layout":
789
+ continue # casing in a column-aligned table is typesetting
652
790
  # Escape sequences are not words: "\\n--- Skills for ..." begins with a
653
791
  # token whose only letter is the n of \\n.
654
792
  readable = re.sub(r"\\[nrt]|\\x[0-9a-fA-F]{2}\[[0-9;]*[A-Za-z]", " ",
@@ -1153,6 +1291,26 @@ EMOJI_RE = re.compile(
1153
1291
  # can actually be established without parsing the sentence.
1154
1292
  DASH = "—"
1155
1293
 
1294
+ # Every spelling of the same mark. The tell is the ROLE the dash plays, not
1295
+ # the codepoint it is written with: swapping "—" for "–", or for a hyphen
1296
+ # with a space each side, leaves the habit exactly where it was, and a check
1297
+ # bound to one codepoint cannot see that it happened. Measured on
1298
+ # trycomp.ai, 2026-08-30: twenty rhetorical dashes, zero em dashes, every
1299
+ # one of them written " - ". See `docs/research/landings/trycomp.md`.
1300
+ DASH_ALIASES = "–‒―"
1301
+
1302
+ # A dash alone in a table cell stands for "no value": a glyph doing the job
1303
+ # of an empty string, not punctuation joining two clauses. AT-06 has said so
1304
+ # since it was written, and until the spellings were normalised nothing
1305
+ # implemented it, so a bare cell dash was reported in every strict locale.
1306
+ TABLE_CELL_DASH_RE = re.compile(rf"(?<=\|)([ \t]*)[{DASH}{DASH_ALIASES}-]([ \t]*)(?=\|)")
1307
+
1308
+ # A hyphen with whitespace on both sides, mid-line. The lookbehind is what
1309
+ # keeps a markdown bullet out of it: a list item's hyphen opens its line, so
1310
+ # nothing non-space precedes the indent in front of it.
1311
+ DASH_HYPHEN_RE = re.compile(r"(?<=\S)([ \t]+)-([ \t]+)(?=\S)")
1312
+ DASH_ALIAS_RE = re.compile(rf"[{DASH_ALIASES}]")
1313
+
1156
1314
  # Never a finding. A range is arithmetic and direct speech is a convention.
1157
1315
  DASH_RANGE_RE = re.compile(rf"\d\s*{DASH}\s*\d")
1158
1316
  DASH_SPEECH_RE = re.compile(rf"^\s*{DASH}\s")
@@ -1185,6 +1343,19 @@ def prose_only(text: str) -> str:
1185
1343
  return text
1186
1344
 
1187
1345
 
1346
+ def normalise_dash_spelling(text: str) -> str:
1347
+ """Reduce every spelling of the rhetorical mark to the canonical dash.
1348
+
1349
+ Length-preserving by construction -- every substitution swaps a single
1350
+ character for a single character -- so a finding can quote the author's
1351
+ own characters while the branches below judge the normalised ones. A
1352
+ quote showing a dash the author never typed sends them grepping for it.
1353
+ """
1354
+ text = TABLE_CELL_DASH_RE.sub(r"\1 \2", text)
1355
+ text = DASH_HYPHEN_RE.sub(rf"\1{DASH}\2", text)
1356
+ return DASH_ALIAS_RE.sub(DASH, text)
1357
+
1358
+
1188
1359
  def sentences(text: str) -> list[str]:
1189
1360
  """Crude split, sufficient to count dashes inside one sentence."""
1190
1361
  return [p for p in re.split(r"(?<=[.!?])\s+|\n\s*\n", text) if p.strip()]
@@ -1203,14 +1374,22 @@ def grammatical_dash_language(text: str, primary: str | None) -> bool:
1203
1374
  return bool(CYRILLIC_RE.search(text))
1204
1375
 
1205
1376
 
1206
- def _around(sentence: str, width: int = 34) -> str:
1207
- """The dash with enough either side to find it and decide the fix."""
1208
- flat = " ".join(sentence.split())
1209
- at = flat.find(DASH)
1377
+ def _around(sentence: str, original: str | None = None, width: int = 34) -> str:
1378
+ """The dash with enough either side to find it and decide the fix.
1379
+
1380
+ `sentence` is normalised and `original` is what the author wrote. The
1381
+ position comes from the first and the characters from the second, which
1382
+ is why the substitutions above are length-preserving: a quote is only
1383
+ useful if it can be found in the file it came from.
1384
+ """
1385
+ source = sentence if original is None else original
1386
+ at = sentence.find(DASH)
1210
1387
  if at < 0:
1211
- return flat[:width * 2]
1212
- start, end = max(0, at - width), min(len(flat), at + width)
1213
- return ("…" if start else "") + flat[start:end] + ("…" if end < len(flat) else "")
1388
+ return " ".join(source.split())[:width * 2]
1389
+ start, end = max(0, at - width), min(len(source), at + width)
1390
+ return (("…" if start else "")
1391
+ + " ".join(source[start:end].split())
1392
+ + ("…" if end < len(source) else ""))
1214
1393
 
1215
1394
 
1216
1395
  def dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding]:
@@ -1222,7 +1401,8 @@ def dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding
1222
1401
  off rather than obeyed.
1223
1402
  """
1224
1403
  findings: list[Finding] = []
1225
- body = prose_only(text)
1404
+ raw = prose_only(text)
1405
+ body = normalise_dash_spelling(raw)
1226
1406
  if DASH not in body:
1227
1407
  return findings
1228
1408
 
@@ -1246,14 +1426,17 @@ def dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding
1246
1426
  break
1247
1427
  return best
1248
1428
 
1249
- for sentence in sentences(body):
1429
+ # `raw` and `body` are the same length and split at the same points --
1430
+ # normalisation touches neither terminal punctuation nor newlines -- so
1431
+ # the two sentence lists stay aligned and the quote comes from the raw.
1432
+ for sentence, original in zip(sentences(body), sentences(raw)):
1250
1433
  if DASH not in sentence:
1251
1434
  continue
1252
1435
  if DASH_SPEECH_RE.match(sentence):
1253
1436
  continue
1254
1437
 
1255
1438
  line = line_of(sentence)
1256
- quoted = _around(sentence)
1439
+ quoted = _around(sentence, original)
1257
1440
 
1258
1441
  conj = DASH_CONJ_RE.search(sentence)
1259
1442
  if conj:
@@ -1429,6 +1612,8 @@ def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
1429
1612
  findings.extend(dash_findings("B062", path, body, strict))
1430
1613
 
1431
1614
  for row in registry(brand_dir):
1615
+ if row["kind"] == "layout":
1616
+ continue
1432
1617
  strict = not grammatical_dash_language(row["text"], primary)
1433
1618
  findings.extend(dash_findings("B062", row["location"], row["text"], strict))
1434
1619
 
@@ -141,6 +141,55 @@ def screen_blocks(text: str) -> dict[str, str]:
141
141
  return entry_blocks(text, "SCR")
142
142
 
143
143
 
144
+ def coverage_subjects(cov: str, root: Path) -> list[tuple[str, str]]:
145
+ """Citations that name a subject, and whether the subject is still there.
146
+
147
+ `B-028`: a range proves its bounds and nothing else. `U071` resolves whether
148
+ the cited lines exist, which caught `bin/super-ux.js:99000-99999` in a
149
+ 396-line file, and cannot catch `223-284` once `selectInteractive` has moved
150
+ to `235-296` -- the exact drift that was live in this pack's own
151
+ `screens.md` for a release. Bounds are all a range can prove about itself.
152
+
153
+ So a citation may name what it is about: `path:start-end symbolName`, the
154
+ symbol separated by a space inside the same backticks. When it does, this
155
+ resolves the symbol in the file and answers whether it is inside the span.
156
+
157
+ It proves the subject is WITHIN the range, not that the range is exactly the
158
+ subject, and that is the honest limit: matching a definition's full extent
159
+ needs a parser per language, and a check that claimed to do it would be
160
+ asserting what it has not measured. Returns `(citation, symbol)` pairs that
161
+ failed.
162
+ """
163
+ bad: list[tuple[str, str]] = []
164
+ for token in re.findall(r"`([^`]+)`", cov):
165
+ parts = token.split()
166
+ if len(parts) != 2:
167
+ continue
168
+ citation, symbol = parts
169
+ if not re.fullmatch(r"[A-Za-z_$][\w$]*", symbol):
170
+ continue
171
+ path, _, span = citation.partition(":")
172
+ match = re.fullmatch(r"(\d+)(?:-(\d+))?", span)
173
+ target = root / path
174
+ if not match or not target.exists():
175
+ continue
176
+ try:
177
+ lines = target.read_text(encoding="utf-8").splitlines()
178
+ except (OSError, UnicodeDecodeError):
179
+ continue
180
+ start = int(match.group(1))
181
+ stop = int(match.group(2)) if match.group(2) else start
182
+ word = re.compile(rf"\b{re.escape(symbol)}\b")
183
+ here = [i for i, l in enumerate(lines, 1)
184
+ if start <= i <= stop and word.search(l)]
185
+ if here:
186
+ continue
187
+ elsewhere = [i for i, l in enumerate(lines, 1) if word.search(l)]
188
+ bad.append((citation, symbol if not elsewhere
189
+ else f"{symbol} (found at line {elsewhere[0]})"))
190
+ return bad
191
+
192
+
144
193
  def coverage_claim(cov: str, root: Path) -> tuple[bool, list[str], list[str]]:
145
194
  """A `Coverage:` value read as the claim about code that it is.
146
195
 
@@ -650,6 +699,33 @@ VISION_SECTIONS = [
650
699
  ]
651
700
 
652
701
  VISION_RULE_HEADING = "## Vision alignment — hard rule (super-ux)"
702
+
703
+ # `B-001`: `validate_hard_rule_copies` compares the template against the copy
704
+ # embedded in the `vision` skill, and both live in THIS repository. A target
705
+ # project's `CLAUDE.md` carries a third copy, installed once and never compared
706
+ # to anything again, so a rule softened by hand or left behind by an upgrade
707
+ # reads exactly like a rule being obeyed. This linter is seeded into that
708
+ # project, so it is the only thing there that can hold the canonical text.
709
+ # `validate.py` gates this constant against `templates/vision-rule.md`, which
710
+ # keeps the number of sources at one.
711
+ VISION_RULE_TEXT = """\
712
+ ## Vision alignment — hard rule (super-ux)
713
+
714
+ Before planning any new feature, capability or significant change, check it
715
+ against `docs/ux/vision.md` — specifically the **anti-vision** and the
716
+ **alignment test**.
717
+
718
+ **Aligned** → proceed, and say in one line which part of the vision it serves.
719
+
720
+ **Misaligned** → stop and say so before writing code:
721
+ 1. Name the conflict — which layer it contradicts, quoting that layer.
722
+ 2. Offer two paths: (a) reshape the feature to fit, with the specific change;
723
+ (b) amend the vision, saying which layer changes and what that costs.
724
+ 3. Wait for the decision. Do not pick one silently.
725
+
726
+ **Do NOT trigger for:** bug fixes, refactors, dependency work, tests,
727
+ documentation, or anything with no user-facing surface. A vision check on a
728
+ typo fix is how a team learns to skip the check that matters."""
653
729
  INSTRUCTION_FILES = ("CLAUDE.md", "AGENTS.md", "GEMINI.md")
654
730
 
655
731
 
@@ -679,16 +755,59 @@ def check_vision(ux: Path, vision: str) -> None:
679
755
  err(f"[U031] vision.md: approved but '## {section}' is empty — "
680
756
  f"the section that settles arguments cannot be blank")
681
757
 
758
+ # `B-005`: the seeded template is nine headings above HTML comments, and
759
+ # `read()` strips comments, so a project that seeds it and writes nothing
760
+ # passes every check here until somebody self-declares `approved`. Silence
761
+ # then reads as a vision, and the alignment rule the `vision` skill
762
+ # installs starts arbitrating against a blank document. A warning rather
763
+ # than an error, because a new project legitimately starts here: the defect
764
+ # is not that it is empty, it is that nothing said so.
765
+ def _authored(section: str) -> bool:
766
+ parts = re.split(rf"^##\s+{re.escape(section)}\s*$", vision, maxsplit=1,
767
+ flags=re.MULTILINE)
768
+ if len(parts) != 2:
769
+ return False
770
+ tail = re.split(r"^##\s", parts[1], maxsplit=1, flags=re.MULTILINE)[0]
771
+ # A `<placeholder>` is the template asking a question, not an answer,
772
+ # and `read()` strips only HTML comments. Section 9 ships three of them
773
+ # as a numbered list, which is why "is anything written here" cannot be
774
+ # answered by `.strip()` alone -- found by watching this check stay
775
+ # silent on the pristine seed it was written for.
776
+ tail = re.sub(r"<[^<>\n]{0,120}>", "", tail)
777
+ tail = re.sub(r"^\s*(?:[-*+]|\d+\.)\s*$", "", tail, flags=re.MULTILINE)
778
+ return bool(tail.strip())
779
+
780
+ written = [s for s in VISION_SECTIONS if _authored(s)]
781
+ if not approved and not written:
782
+ warn(f"[U076] vision.md is still the seeded template — all "
783
+ f"{len(VISION_SECTIONS)} sections are headings with nothing under "
784
+ f"them, so the alignment rule is arbitrating against a blank "
785
+ f"document. Write it, or delete the file until you do")
786
+
682
787
  root = ux.parent.parent if ux.name == "ux" else ux.parent
683
788
  present = [root / n for n in INSTRUCTION_FILES if (root / n).is_file()]
684
789
  if not present:
685
790
  warn("[U032] vision.md exists but the project has no CLAUDE.md / AGENTS.md / "
686
791
  "GEMINI.md — the alignment rule has nowhere to live")
687
792
  return
688
- if not any(VISION_RULE_HEADING in read(p) for p in present):
793
+ carrying = [p for p in present if VISION_RULE_HEADING in read(p)]
794
+ if not carrying:
689
795
  warn(f"[U033] vision.md exists but no '{VISION_RULE_HEADING}' block in "
690
796
  f"{', '.join(p.name for p in present)} — nothing ever reads the vision "
691
797
  f"(run the `vision` skill's step 4)")
798
+ return
799
+ for path in carrying:
800
+ text = read(path)
801
+ start = text.index(VISION_RULE_HEADING)
802
+ tail = text[start + len(VISION_RULE_HEADING):]
803
+ stop = re.search(r"^##\s", tail, re.MULTILINE)
804
+ installed = (VISION_RULE_HEADING
805
+ + (tail[:stop.start()] if stop else tail)).strip()
806
+ if installed != VISION_RULE_TEXT.strip():
807
+ warn(f"[U077] {path.name}: the installed vision rule differs from the "
808
+ f"one this version ships — a rule edited by hand or left behind "
809
+ f"by an upgrade reads exactly like a rule being obeyed. Re-run "
810
+ f"the `vision` skill's step 4, or keep the edit deliberately")
692
811
 
693
812
 
694
813
  def check_links(ux: Path) -> None:
@@ -849,6 +968,10 @@ def main() -> int:
849
968
  warn(f"[U055] screens.md: {sid} claims Coverage '{cov}' and names no file")
850
969
  for rel in missing:
851
970
  err(f"[U056] screens.md: {sid} cites '{rel}', which does not exist")
971
+ for citation, symbol in coverage_subjects(cov, screens_root):
972
+ warn(f"[U078] screens.md: {sid} cites '{citation}' as covering "
973
+ f"`{symbol}`, which is not inside those lines — the range "
974
+ f"proves its bounds and the subject is what it is about")
852
975
  for rel in beyond:
853
976
  err(f"[U071] screens.md: {sid} cites '{rel}' — the file resolves "
854
977
  f"and those lines do not, so the citation points at code "
@@ -5,7 +5,8 @@ Locale parity threshold: 80%
5
5
  Derived-from: <P-NN, JTBD-NN from docs/ux/foundation.md — or `inferred`>
6
6
  Status: draft
7
7
  Last calibrated: <YYYY-MM-DD>
8
- Humanization pass: <own | humanizer | avoid-ai-writing | none> # optional; absent = ask once
8
+ Humanization: on # on | off; on is the default, off needs a reason below
9
+ Humanization pass: <own | humanizer | avoid-ai-writing> # optional; absent = own
9
10
 
10
11
  # Voice
11
12