super-ux 0.55.0 → 0.56.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,62 @@
1
+ ## 0.56.0 — the sherlock audit closes, and two prototype documents stop pretending to be one
2
+
3
+ Sherlock external-v3, 41 findings across this member, each one a commit carrying
4
+ its own executable regression under `test/audit_regressions/`. The ones worth
5
+ naming here:
6
+
7
+ - **Scenarios carry three axes, not one word.** `evidence_kind`,
8
+ `decision_status` and `validation_status` are separate: approval moves the
9
+ decision, and only a dated interview or observation moves validation. A founder
10
+ cannot promote a wished-for persona to `observed` by approving it.
11
+ - **Competitor funnels are observed exposure, never a proven base.** The reference
12
+ admitted a well-funded loss-maker looks profitable and then called common
13
+ patterns proven; it now says what it can see. Frequency is computed with a
14
+ denominator, duplicates collapsed — two funnels of one owner are ONE
15
+ observation — and adoption is decided by a local experiment.
16
+ - **`/ux-audit` preconditions are per scope.** A standalone blog with a brand pack
17
+ and no scenarios runs the copy audit instead of being routed into writing
18
+ scenarios it has no use for. Evidence is typed by claim: `file:line` for this
19
+ codebase, URL + timestamp + capture for anything outside it.
20
+ - **BP-212 no longer calls local payment testing impossible.** Three environments:
21
+ a local sandbox tests the whole payment→webhook→entitlement→success path with
22
+ the provider's own forwarding, and a public HTTPS endpoint is what PRODUCTION
23
+ delivery needs.
24
+
25
+ Two documents now share the ground one filename used to: `interactive-flow-prototypes.md`
26
+ is the machine-checkable graph contract (`IFP-01 … IFP-06`), and
27
+ `clickable-flow-prototypes.md` is the operator-facing practice that arrived with the
28
+ context-ready handoff. `prototyping.md` owns the decision to reach for either.
29
+
30
+ `ux-audit` and `ux-flows` were split back under the house working limit —
31
+ `audit-depth.md` and `prototyping.md` — after the audit's own doctrine grew them
32
+ past it. CI now MEASURES that budget with a real tokenizer instead of estimating it.
33
+
34
+ ## 0.55.1 — the opt-out the router promises now lives in the bodies that honor it
35
+
36
+ Family audit 2026-09-06, wave AUDIT-WAVE-0906. **The operator's routing block
37
+ promises a spoken refusal for each chain — "no scenarios" / «без сценариев» for
38
+ the UX chain, "no brand" / «без бренда» (plus "draft it" / «черновиком» for
39
+ copywriting) for the copy chain — and no skill text in this pack carried any of
40
+ them.** With the routing block installed the promise held one layer up; a
41
+ standalone install advertised no opt-out at all, so an operator who wanted the
42
+ route skipped had no phrase the skill had agreed to hear.
43
+
44
+ One sentence in each of the seven bodies now states it: the operator declines
45
+ the route by saying the phrase, and the skill proceeds without the chain while
46
+ saying so — never silently. Measured before and after with the house auditor:
47
+ every body stays inside its working limit, the tightest being `ux-audit`
48
+ ~4626/4750 and `ux-flows` ~4529/4750 tokens.
49
+
50
+ **No `description:` changed, by decision rather than accident.** Descriptions
51
+ pair with the umbrella's trigger table, and a dropped advertised phrase refuses
52
+ the family pin. `ux-flows`'s description stands at 965/1024 — 965 of the 970
53
+ working limit — and is recorded here untouched for exactly that reason:
54
+ `git diff -U0` over the skills shows zero changed `description:` lines.
55
+
56
+ `repo validator checks` re-measured at 4623 by running the command
57
+ `docs/brand/facts.md` names — the count did not move with this change, and
58
+ that is a measurement, not a restatement.
59
+
1
60
  ## 0.55.0 — the ledger names the version it was measured on, because 0.54.0's did not
2
61
 
3
62
  **A repair for what the previous release shipped, and the gate that would have caught
package/bin/super-ux.js CHANGED
@@ -25,9 +25,16 @@ const ROOT = path.resolve(__dirname, '..');
25
25
  const REPO = 'ssheleg/super-ux';
26
26
  const NAME = 'super-ux';
27
27
 
28
- // Exit codes are the contract: 0 installed or nothing selected, 1 error,
29
- // 3 refused — the plugin channel owns this agent (--force overrides).
28
+ // Exit codes are the contract: 0 installed/unchanged or nothing selected,
29
+ // 1 a selected operation FAILED (the backend's exit/signal/command are printed
30
+ // and preserved on the result),
31
+ // 3 refused — the plugin channel owns this agent (--force overrides),
32
+ // 4 unsupported — a selected channel's backend is absent (claude CLI/npx not
33
+ // found); the remedy is printed, and 4 says "nothing was installed" out loud.
34
+ // Failure outranks refusal outranks unsupported when several were selected.
30
35
  const EXIT_PLUGIN_PRESENT = 3;
36
+ const EXIT_FAILED = 1;
37
+ const EXIT_UNSUPPORTED = 4;
31
38
 
32
39
  /**
33
40
  * The plugin spec (`<name>@<marketplace>`) installed for `name` in this home,
@@ -41,10 +48,23 @@ const EXIT_PLUGIN_PRESENT = 3;
41
48
  * HOME is the common case, and an installer that crashes on a parse error
42
49
  * refuses the machines that need it most.
43
50
  */
51
+ // The bundled HostContext resolver (FIX-UP-08.02) — one contract, a local
52
+ // copy per member (npx installers share no lib): a host's config root is an
53
+ // explicit root > the documented host env var > `~/<dir>`, verbatim (spaces
54
+ // preserved), and host existence is a separate probe on the returned path.
55
+ const HOST_ENV = { claude: 'CLAUDE_CONFIG_DIR', codex: 'CODEX_HOME', gemini: 'GEMINI_CONFIG_DIR' };
56
+ const HOST_DIR = { claude: '.claude', codex: '.codex', gemini: '.gemini' };
57
+ function hostRoot(agent, home, env, explicit) {
58
+ if (explicit) return explicit;
59
+ const e = (env || process.env)[HOST_ENV[agent]];
60
+ if (e) return e;
61
+ return path.join(home, HOST_DIR[agent]);
62
+ }
63
+
44
64
  function installedPluginSpec(home, name) {
45
65
  try {
46
66
  const raw = fs.readFileSync(
47
- path.join(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf8');
67
+ path.join(hostRoot('claude', home, process.env), 'plugins', 'installed_plugins.json'), 'utf8');
48
68
  const parsed = JSON.parse(raw);
49
69
  const plugins =
50
70
  parsed && typeof parsed === 'object' &&
@@ -199,8 +219,11 @@ function installCursor(target, force) {
199
219
 
200
220
  function run(cmd, args) {
201
221
  const result = spawnSync(cmd, args, { stdio: 'inherit' });
202
- if (result.error && result.error.code === 'ENOENT') return 'missing';
203
- return result.status === 0 ? 'ok' : 'failed';
222
+ const command = [cmd, ...args].join(' ');
223
+ if (result.error && result.error.code === 'ENOENT')
224
+ return { status: 'missing', code: null, signal: null, command };
225
+ return { status: result.status === 0 ? 'ok' : 'failed',
226
+ code: result.status, signal: result.signal ?? null, command };
204
227
  }
205
228
 
206
229
  /**
@@ -219,7 +242,7 @@ function run(cmd, args) {
219
242
  function installSkillsCli(force) {
220
243
  const home = os.homedir();
221
244
  const spec = installedPluginSpec(home, NAME);
222
- const marketplace = path.join(home, '.claude', 'plugins', 'marketplaces', NAME);
245
+ const marketplace = path.join(hostRoot('claude', home, process.env), 'plugins', 'marketplaces', NAME);
223
246
  const viaMarketplaceDir = !spec && fs.existsSync(marketplace);
224
247
  if ((spec || viaMarketplaceDir) && !force) {
225
248
  const found = spec
@@ -241,9 +264,17 @@ function installSkillsCli(force) {
241
264
  return 'refused';
242
265
  }
243
266
  console.log(`\n--- Skills for any agent: delegating to the skills CLI picker ---`);
244
- const status = run('npx', ['--yes', 'skills', 'add', REPO]);
245
- if (status !== 'ok') console.error(`warning: 'npx skills add ${REPO}' ${status}`);
246
- return status;
267
+ const r = run('npx', ['--yes', 'skills', 'add', REPO]);
268
+ if (r.status === 'missing') {
269
+ console.error('The npx command was not found; install Node.js to use the skills channel.');
270
+ return { status: 'unsupported', ...r };
271
+ }
272
+ if (r.status === 'failed') {
273
+ console.error(`error: '${r.command}' exited ${r.code}` +
274
+ (r.signal ? ` (signal ${r.signal})` : ''));
275
+ return { status: 'failed', ...r };
276
+ }
277
+ return { status: 'installed', ...r };
247
278
  }
248
279
 
249
280
  /**
@@ -268,16 +299,21 @@ function installClaudePlugin() {
268
299
  console.log(`claude CLI not found. Run inside Claude Code instead:
269
300
  /plugin marketplace add ${REPO}
270
301
  /plugin install super-ux@super-ux`);
271
- return;
302
+ return { status: 'unsupported', code: null, signal: null,
303
+ command: 'claude --version' };
272
304
  }
273
- if (run('claude', ['plugin', 'marketplace', 'add', REPO]) !== 'ok') {
305
+ if (run('claude', ['plugin', 'marketplace', 'add', REPO]).status !== 'ok') {
274
306
  console.log('(marketplace may already be added, continuing)');
275
307
  }
276
- if (run('claude', ['plugin', 'install', 'super-ux@super-ux']) === 'ok') {
308
+ const r = run('claude', ['plugin', 'install', 'super-ux@super-ux']);
309
+ if (r.status === 'ok') {
277
310
  console.log('Claude Code plugin installed (scope: user). Restart sessions to pick it up; then run /ux in any project.');
278
- } else {
279
- console.error('warning: claude plugin install failed, see output above');
311
+ return { status: 'installed', ...r };
280
312
  }
313
+ console.error(`error: '${r.command}' exited ${r.code}` +
314
+ (r.signal ? ` (signal ${r.signal})` : ''));
315
+ console.error('The plugin did not install; see the command output above.');
316
+ return { status: 'failed', ...r };
281
317
  }
282
318
 
283
319
  function makePrompter() {
@@ -441,20 +477,48 @@ async function menu(force) {
441
477
  }
442
478
  if (prompter) prompter.close();
443
479
 
444
- if (keys.includes('cursor')) installCursor(cursorDir, false);
445
- if (keys.includes('claude')) installClaudePlugin();
446
- let refused = false;
447
- if (keys.includes('skills')) refused = installSkillsCli(force) === 'refused';
480
+ // Only the SELECTED install operations are the result — each labelled by its
481
+ // channel, so a mixed selection reports which one failed rather than a single
482
+ // aggregate verdict. The router offer below is optional enrichment and is
483
+ // deliberately NOT in this list: whether it prints a block cannot change
484
+ // whether the install succeeded.
485
+ const results = [];
486
+ if (keys.includes('cursor')) { installCursor(cursorDir, false); results.push({ channel: 'cursor', status: 'installed' }); }
487
+ if (keys.includes('claude')) results.push({ channel: 'claude', ...installClaudePlugin() });
488
+ if (keys.includes('skills')) {
489
+ const r = installSkillsCli(force);
490
+ results.push({ channel: 'skills', ...(r === 'refused' ? { status: 'refused' } : r) });
491
+ }
448
492
 
449
493
  // Same offer the --cursor flag path makes. Two doors into one install that
450
494
  // behave differently is how a feature comes to exist for half its users.
451
495
  // Offered on the refused path too: the skill IS present on this machine —
452
- // as the plugin — so the routing block is exactly as wanted.
453
- offerRouters();
454
- if (refused) {
496
+ // as the plugin — so the routing block is exactly as wanted. Guarded: an
497
+ // optional offer that threw must not become an install failure.
498
+ try { offerRouters(); } catch (e) {
499
+ console.error(`note: the routing-block offer could not run (${e.message}); the install above is unaffected`);
500
+ }
501
+
502
+ // The exit code is computed from the TYPED results of the SELECTED
503
+ // operations, never from the last print and never from the optional router
504
+ // offer: a failed child that ends in exit 0 reads as success to every script
505
+ // above it. Failure outranks refusal outranks unsupported.
506
+ const statuses = results.map((r) => r.status);
507
+ const failed = results.filter((r) => r.status === 'failed').map((r) => r.channel);
508
+ const ok = results.filter((r) => r.status === 'installed').map((r) => r.channel);
509
+ if (statuses.includes('failed')) {
510
+ // PARTIAL: name what installed and what failed rather than one word.
511
+ if (ok.length)
512
+ console.error(`partial: installed ${ok.join(', ')}; FAILED ${failed.join(', ')} — see the errors above`);
513
+ else
514
+ console.error(`failed: ${failed.join(', ')} — see the errors above`);
515
+ process.exitCode = EXIT_FAILED;
516
+ } else if (statuses.includes('refused')) {
455
517
  // The refusal already carries the update commands; repeating the update
456
518
  // line under it would bury the remedy. Exit 3 so scripts read the refusal.
457
519
  process.exitCode = EXIT_PLUGIN_PRESENT;
520
+ } else if (statuses.includes('unsupported')) {
521
+ process.exitCode = EXIT_UNSUPPORTED;
458
522
  } else {
459
523
  printUpdateLine();
460
524
  }
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "super-ux",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "scripts": {
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"
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 && npm run test:audit",
6
+ "test:audit": "for t in test/audit_regressions/*.py; do python3 \"$t\" || exit 1; done"
6
7
  },
7
8
  "description": "Scenario-driven UI development for AI agents (Claude Code, Cursor, 70+ agents): a versioned design chain in docs/ux/, a scenario-first hard rule, a deterministic drift linter, and evidence-backed UX audits. This package is the installer CLI.",
8
9
  "bin": {
@@ -1063,40 +1063,115 @@ def _today() -> str:
1063
1063
  return datetime.date.today().isoformat()
1064
1064
 
1065
1065
 
1066
- def _fact_figures(value: str) -> set[str]:
1067
- """Every figure ONE fact's `Value` sources, normalised the way copy is read.
1068
-
1069
- B030 compares a figure in public copy against this set exactly. Until
1070
- 2026-08-20 it compared against every value joined into a single string and
1071
- asked `compact not in known.replace(" ", "")` -- so the corpus was one
1072
- character sequence and every SUBSTRING of it counted as sourced. With this
1073
- pack's own seven public rows the corpus was `7158215243770+`, which sourced
1074
- the invented `1582` in "super-ux ... ships 1582 checks": the linter printed
1075
- `brand pack is clean` and exited 0. An invented public number passing the
1076
- check whose entire purpose is to refuse one is the worst failure this file
1077
- can have, because it is indistinguishable from working.
1078
-
1079
- Three forms are accepted, and each is the same claim written differently:
1080
- the value with its whitespace removed; the same with a bound marker stripped,
1081
- so a row of `500+` sources the `500` a sentence writes; and whatever the
1082
- figure regex reads INSIDE the value, so `$3.10` and a thousands separator are
1083
- matched in the form copy uses. Nothing else -- a figure not written in the
1084
- table is not in the table.
1066
+ # A figure's provenance is more than its digits (FIX-UX-01.02 / UX-01). "500
1067
+ # supported integrations" does NOT source "500 million paying customers": the
1068
+ # digits coincide, the SCALE and the SUBJECT differ. So B030 resolves a claim
1069
+ # against a SIGNATURE -- (digits, unit, scale, precision) -- not against a bag
1070
+ # of numbers. Subject/population match is a reader's judgement, kept to the
1071
+ # separate semantic review; this token-level check verifies the provenance
1072
+ # LINK, the UNIT, the PRECISION and the date TYPE, and says so in its own name.
1073
+ SCALE_WORDS = {
1074
+ "k": "k", "thousand": "k", "thousands": "k",
1075
+ "m": "m", "mn": "m", "million": "m", "millions": "m",
1076
+ "b": "b", "bn": "b", "billion": "b", "billions": "b",
1077
+ }
1078
+ # Words that follow a year but do not make it a COUNT ("2025 and Material").
1079
+ _NOT_A_COUNT_NOUN = {
1080
+ "and", "or", "but", "to", "the", "of", "a", "an", "rev", "edition",
1081
+ "version", "release", "guidelines", "guidance",
1082
+ }
1083
+ # Cues that a nearby four-digit token is a DATE, not a figure.
1084
+ _DATE_CUE = re.compile(
1085
+ r"(?:©|\bin\b|\bsince\b|\bcirca\b|\bas of\b|\best\.?\b|\brev\b"
1086
+ r"|\bedition\b|\bversion\b|\bv\b|\bHIG\b|\bSP\b|\bISO\b|\bRFC\b"
1087
+ r"|\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\b)",
1088
+ re.IGNORECASE,
1089
+ )
1090
+
1091
+
1092
+ def _signature(token: str, scale_word: str = "") -> tuple:
1093
+ """(digits, unit, scale, precision) for one numeric token.
1094
+
1095
+ Digits keep their decimal places so precision is part of identity: a row of
1096
+ `$3.10` does not source a copy `$3.1`. Unit is % or a currency mark; scale
1097
+ is a magnitude word beside the number, so a bare `500` never sources
1098
+ `500 million`.
1085
1099
  """
1086
- compact = re.sub(r"\s+", "", value)
1087
- figures = {compact, compact.strip("+~><≈").strip()}
1088
- figures.update(m.replace(" ", "") for m in NUMBER_RE.findall(value))
1089
- return {f for f in figures if f}
1100
+ s = token.strip()
1101
+ unit = ""
1102
+ if s.endswith("%"):
1103
+ unit, s = "%", s[:-1].strip()
1104
+ m = re.match(r"^([$€£])\s?(.*)$", s)
1105
+ if m:
1106
+ unit, s = m.group(1), m.group(2)
1107
+ s = s.replace(",", "").strip("+~><≈").strip()
1108
+ if "." in s:
1109
+ intp, _dot, frac = s.partition(".")
1110
+ digits, precision = intp + "." + frac, len(frac)
1111
+ else:
1112
+ digits, precision = s, 0
1113
+ scale = SCALE_WORDS.get((scale_word or "").lower(), "")
1114
+ return (digits, unit, scale, precision)
1115
+
1116
+
1117
+ def _row_signatures(value: str) -> set:
1118
+ """Every SIGNATURE one fact's `Value` sources (replaces the old digit set).
1119
+
1120
+ Same anti-substring guarantee as before -- a figure not written in the
1121
+ table is not in the table -- now carrying unit, scale and precision so a
1122
+ coincidence of digits can no longer launder a different claim.
1123
+ """
1124
+ sigs = set()
1125
+ for m in NUMBER_RE.finditer(value):
1126
+ tail = value[m.end():].lstrip()
1127
+ sw = re.match(r"([A-Za-z]+)", tail)
1128
+ scale_word = sw.group(1) if sw and sw.group(1).lower() in SCALE_WORDS else ""
1129
+ sigs.add(_signature(m.group(0), scale_word))
1130
+ compact = re.sub(r"\s+", "", value).strip("+~><≈").strip()
1131
+ if compact and re.search(r"\d", compact):
1132
+ sigs.add(_signature(compact))
1133
+ return sigs
1134
+
1135
+
1136
+ def _reads_as_date(body: str, start: int, end: int) -> bool:
1137
+ """A four-digit year-form token is a DATE (not a checkable figure) by
1138
+ default; it is a FIGURE -- and needs a row -- only when a plural count noun
1139
+ follows it with no date cue before and no range dash beside it. So
1140
+ "2026 integrations" and "2026 customers" are checked, while "Apple HIG
1141
+ 2025", "in 2026", "2020—2024" and "2024 without a gap" stay dates.
1142
+
1143
+ A plural count noun is approximated as a lowercase word of four or more
1144
+ letters ending in "s" and not in the small stoplist -- deliberately narrow,
1145
+ because a false FIGURE here is a B030 nobody can clear (the range fixture
1146
+ that caught exactly this)."""
1147
+ before = body[max(0, start - 24):start]
1148
+ if _DATE_CUE.search(before) or before.rstrip().endswith(("-", "\u2013", "\u2014")):
1149
+ return True
1150
+ after = body[end:]
1151
+ if after.lstrip().startswith(("-", "\u2013", "\u2014")):
1152
+ return True # a range: 2020—2024
1153
+ # A year preceded by a proper noun or an acronym is an edition/benchmark
1154
+ # year ("Apple HIG 2025", "PLG 2025 benchmarks"), not a count. Only a year
1155
+ # after a lowercase word (or nothing) — "spanning 2026 integrations" — reads
1156
+ # as the count itself.
1157
+ prev = re.search(r"([A-Za-z][A-Za-z-]*)[\s/]*$", before)
1158
+ prev_is_name = bool(prev and (prev.group(1)[:1].isupper() or prev.group(1).isupper()))
1159
+ nxt = re.match(r"\s*([A-Za-z][A-Za-z-]*)", after)
1160
+ if nxt and not prev_is_name:
1161
+ w = nxt.group(1).lower()
1162
+ if len(w) >= 4 and w.endswith("s") and w not in _NOT_A_COUNT_NOUN:
1163
+ return False # a count noun follows: check it
1164
+ return True
1090
1165
 
1091
1166
 
1092
1167
  def check_facts(brand_dir: Path, sources: dict) -> list[Finding]:
1093
1168
  """B030-B033 -- every figure traces to a row, every row to a source."""
1094
1169
  findings: list[Finding] = []
1095
1170
  rows = facts(brand_dir)
1096
- known: set[str] = set()
1171
+ known: set = set()
1097
1172
  for row in rows:
1098
1173
  if row["public"].lower() != "no":
1099
- known |= _fact_figures(row["value"])
1174
+ known |= _row_signatures(row["value"])
1100
1175
 
1101
1176
  # B033 -- `Fact` is the key a figure is cited by, and a table with two rows
1102
1177
  # under one key has no answer to "what is that number". Watched: a second
@@ -1132,16 +1207,31 @@ def check_facts(brand_dir: Path, sources: dict) -> list[Finding]:
1132
1207
  ))
1133
1208
 
1134
1209
  for path, _fields, body in documents(brand_dir, sources, "marketing"):
1135
- for number in NUMBER_RE.findall(body):
1136
- compact = number.replace(" ", "")
1137
- if YEAR_RE.match(compact):
1210
+ for m in NUMBER_RE.finditer(body):
1211
+ number = m.group(0)
1212
+ digits = re.sub(r"\s+", "", number)
1213
+ # A year-form token: excluded ONLY when it reads as a date, never
1214
+ # automatically (UX-01). "2026 integrations" is a figure that needs
1215
+ # a row; "Apple HIG 2025" is a date.
1216
+ if YEAR_RE.match(digits.strip("+~><≈")) and _reads_as_date(body, m.start(), m.end()):
1138
1217
  continue
1139
- if compact not in known:
1218
+ tail = body[m.end():].lstrip()
1219
+ sw = re.match(r"([A-Za-z]+)", tail)
1220
+ scale_word = sw.group(1) if sw and sw.group(1).lower() in SCALE_WORDS else ""
1221
+ sig = _signature(number, scale_word)
1222
+ if sig not in known:
1223
+ # Name WHICH axis failed, so a writer can act: a bare digit that
1224
+ # a row DOES carry under a different unit/scale/precision is a
1225
+ # provenance mismatch, not a missing fact.
1226
+ bare_matches = any(s[0] == sig[0] for s in known)
1227
+ why = ("its unit, scale or precision does not match any facts.md row"
1228
+ if bare_matches else "no row in facts.md")
1140
1229
  findings.append(Finding(
1141
1230
  "B030", SEVERITY_ERROR, path, 0,
1142
- f"`{number}` appears in public copy with no row in "
1143
- f"facts.md -- a number nobody can check is a claim "
1144
- f"nobody should make",
1231
+ f"`{number}` in public copy: {why} -- a number nobody can "
1232
+ f"check against a sourced claim is a claim nobody should make. "
1233
+ f"(Whether it means what the sentence says is the semantic "
1234
+ f"review's question, not this check's.)",
1145
1235
  ))
1146
1236
  for paragraph in re.split(r"\n\s*\n", body):
1147
1237
  lowered = paragraph.lower()
@@ -1272,6 +1362,38 @@ S1_MARKERS = (
1272
1362
  "crucial to", "navigate the complexities",
1273
1363
  )
1274
1364
 
1365
+ # B051 keyword-stuffing is ADVISORY and about UNNATURAL REPETITION (FIX-UX-02.01):
1366
+ # a token must repeat many times on a page long enough to read, not merely clear
1367
+ # a percentage. These bars make a zero-repeat page (45/80/100 unique words)
1368
+ # always clean, and leave a genuinely stuffed block to warn.
1369
+ B051_MIN_WORDS = 40 # enough words to talk about; repetition COUNT guards the rest
1370
+ B051_MIN_REPEAT = 5 # a word must actually recur, not appear once
1371
+ B051_SHARE = 0.04 # and take an unnatural share on top of that
1372
+
1373
+
1374
+ def _domain_terms(brand_dir) -> set:
1375
+ """Registered terms that are SUPPOSED to recur — exempt from B051. Drawn
1376
+ from terminology.md (product terms, entity names) and facts.md fact names,
1377
+ so a brand's own vocabulary is never read as stuffing."""
1378
+ terms = set()
1379
+ try:
1380
+ _banned, products, entities = dictionary(brand_dir)
1381
+ for pair in products:
1382
+ terms.add(pair[0].lower())
1383
+ for pair in entities:
1384
+ terms.add(pair[0].lower())
1385
+ except Exception:
1386
+ pass
1387
+ try:
1388
+ for row in facts(brand_dir):
1389
+ for tok in normalise(row["fact"]).lower().split():
1390
+ if len(tok) > 3:
1391
+ terms.add(tok)
1392
+ except Exception:
1393
+ pass
1394
+ return terms
1395
+
1396
+
1275
1397
  STOPWORDS = {
1276
1398
  "the", "and", "for", "with", "that", "this", "from", "your", "you",
1277
1399
  "are", "was", "were", "have", "has", "had", "not", "but", "all",
@@ -1527,22 +1649,39 @@ def check_bot_safety(brand_dir: Path, sources: dict) -> list[Finding]:
1527
1649
  if len(pooled) > 1 else pooled[0][0])
1528
1650
  per_file.append((label, {}, "\n\n".join(d[2] for d in pooled)))
1529
1651
 
1652
+ # Keyword stuffing is UNNATURAL REPETITION, not a frequency threshold. The
1653
+ # old rule fired B051 (an ERROR) on any token above 1% of the words once a
1654
+ # page passed 40 significant words -- but with 45 unique words and NO repeat
1655
+ # each token is 1/45 = 2.2%, so a page that repeats nothing was flagged, and
1656
+ # for a short page the 1% bar is mathematically unmeetable. Google's policy
1657
+ # describes unnatural repetition / manipulative intent, not a 1% line
1658
+ # (https://developers.google.com/search/docs/essentials/spam-policies#keyword-stuffing),
1659
+ # so this is now ADVISORY: a word must actually REPEAT many times on a page
1660
+ # long enough to judge, and a registered domain term (which is SUPPOSED to
1661
+ # recur) is exempt. Each page is judged on its own rendered body.
1662
+ domain_terms = _domain_terms(brand_dir)
1530
1663
  for path, fields, body in per_file:
1531
1664
  words = [w.lower().strip(".,:;!?()\"'") for w in body.split()]
1532
1665
  real = [w for w in words if len(w) > 3 and w not in STOPWORDS]
1533
- if len(real) >= 40:
1534
- counts: dict[str, int] = {}
1535
- for word in real:
1536
- counts[word] = counts.get(word, 0) + 1
1537
- for word, count in sorted(counts.items()):
1538
- if count / len(words) > 0.01:
1539
- findings.append(Finding(
1540
- "B051", SEVERITY_ERROR, path, 0,
1541
- f"`{word}` is {count / len(words):.1%} of the "
1542
- f"document -- above 1% reads as stuffing, which "
1543
- f"lowers citation likelihood rather than raising it",
1544
- ))
1545
- break
1666
+ if len(words) < B051_MIN_WORDS:
1667
+ continue # too short to read repetition at all
1668
+ counts: dict[str, int] = {}
1669
+ for word in real:
1670
+ counts[word] = counts.get(word, 0) + 1
1671
+ for word, count in sorted(counts.items()):
1672
+ if word in domain_terms:
1673
+ continue # a registered term is meant to recur
1674
+ share = count / len(words)
1675
+ if count >= B051_MIN_REPEAT and share > B051_SHARE:
1676
+ findings.append(Finding(
1677
+ "B051", SEVERITY_WARN, path, 0,
1678
+ f"`{word}` repeats {count}x ({share:.1%}) on this page -- "
1679
+ f"advisory: unnatural repetition reads as keyword stuffing "
1680
+ f"(Google's spam policy is about manipulative repetition, "
1681
+ f"not a fixed percentage). If it is a registered term, add "
1682
+ f"it to terminology.md; otherwise vary the wording",
1683
+ ))
1684
+ break
1546
1685
 
1547
1686
  for path, fields, body in marketing:
1548
1687
 
@@ -1597,11 +1736,15 @@ def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
1597
1736
  if not hits:
1598
1737
  continue
1599
1738
  grade = "B" if len(hits) < 3 else "C"
1600
- severity = SEVERITY_ERROR if len(hits) >= 3 else SEVERITY_WARN
1739
+ # B060 is ADVISORY (FIX-UX-03.01/03.02): it WARNS, and it NEVER escalates
1740
+ # to an error by marker count. A count is a signal to a writer, not proof
1741
+ # of authorship and not a gate — so marker-rich but correct text does not
1742
+ # block the run. The grade stays as an advisory reading of the density.
1601
1743
  findings.append(Finding(
1602
- "B060", severity, path, 0,
1744
+ "B060", SEVERITY_WARN, path, 0,
1603
1745
  f"{len(hits)} S1 marker(s) -- {', '.join(sorted(hits))}. "
1604
- f"Naturalness grade {grade}",
1746
+ f"Naturalness grade {grade} (advisory — vary the wording if you wish; "
1747
+ f"a marker count is not proof of authorship)",
1605
1748
  ))
1606
1749
 
1607
1750
  # B062 -- the rhetorical dash, in every surface that ships prose.
@@ -686,17 +686,18 @@ def check_web_surface(screens: str, flows: str) -> None:
686
686
  )
687
687
 
688
688
 
689
- VISION_SECTIONS = [
690
- "1. Essence",
691
- "2. Core idea",
692
- "3. What the system does",
693
- "4. The user's role",
694
- "5. Principles",
695
- "6. Anti-vision",
696
- "7. Horizon",
697
- "8. The one sentence",
698
- "9. The alignment test",
699
- ]
689
+ # The machine IDENTITY of a vision section is its NUMBER (FIX-UX-07.01); the
690
+ # title after it is DISPLAY prose and may be localized. The linter matches on
691
+ # `## N.` so a Russian vision keeps all nine section ids without English titles.
692
+ # The English titles here are the seed default, not the parser's key.
693
+ VISION_SECTION_TITLES = {
694
+ 1: "Essence", 2: "Core idea", 3: "What the system does", 4: "The user's role",
695
+ 5: "Principles", 6: "Anti-vision", 7: "Horizon", 8: "The one sentence",
696
+ 9: "The alignment test",
697
+ }
698
+ VISION_SECTION_IDS = list(VISION_SECTION_TITLES) # [1..9]
699
+ # The number is the id: `## 6.` or `## 6. Anti-vision` or `## 6. Анти-видение`.
700
+ _VSEC = lambda n: rf"^##\s+{n}\.[^\n]*$" # whole heading line: title (any language) is part of the heading, not the body
700
701
 
701
702
  VISION_RULE_HEADING = "## Vision alignment — hard rule (super-ux)"
702
703
 
@@ -728,6 +729,20 @@ documentation, or anything with no user-facing surface. A vision check on a
728
729
  typo fix is how a team learns to skip the check that matters."""
729
730
  INSTRUCTION_FILES = ("CLAUDE.md", "AGENTS.md", "GEMINI.md")
730
731
 
732
+ # Which instruction file each HOST actually reads, and the marker directory that
733
+ # says the host is present in this project (FIX-UX-06.01). A rule in CLAUDE.md
734
+ # does not cover a Codex host, which reads AGENTS.md — so the check is per active
735
+ # host, not "any of the three files exists".
736
+ HOST_TARGET = {"claude": "CLAUDE.md", "codex": "AGENTS.md", "gemini": "GEMINI.md"}
737
+ HOST_MARKER = {"claude": ".claude", "codex": ".codex", "gemini": ".gemini"}
738
+
739
+
740
+ def active_hosts(root: Path) -> list[str]:
741
+ """Hosts present in this project, by their marker directory. When none is
742
+ detectable the target defaults to Claude, matching the seed default."""
743
+ found = [h for h, m in HOST_MARKER.items() if (root / m).is_dir()]
744
+ return found or ["claude"]
745
+
731
746
 
732
747
  def check_vision(ux: Path, vision: str) -> None:
733
748
  """The vision layer: all nine sections, and the rule that makes it read.
@@ -738,21 +753,31 @@ def check_vision(ux: Path, vision: str) -> None:
738
753
  """
739
754
  if not vision.strip():
740
755
  return
741
- for section in VISION_SECTIONS:
742
- if not re.search(rf"^##\s+{re.escape(section)}\s*$", vision, re.MULTILINE):
743
- err(f"[U030] vision.md: missing section '## {section}'")
756
+ for n in VISION_SECTION_IDS:
757
+ hits = re.findall(_VSEC(n), vision, re.MULTILINE)
758
+ if not hits:
759
+ err(f"[U030] vision.md: missing section '## {n}.' "
760
+ f"(e.g. '{n}. {VISION_SECTION_TITLES[n]}' — the number is the id, "
761
+ f"the title may be in the document's language)")
762
+ elif len(hits) > 1:
763
+ # FIX-UX-07.02: the id is the number, so two `## N.` headings are two
764
+ # sections claiming one identity — a duplicate the parser would
765
+ # otherwise resolve to whichever it found first, silently.
766
+ err(f"[U034] vision.md: section id {n} appears {len(hits)} times — "
767
+ f"a section id is unique; give one of them a different number or "
768
+ f"merge them (found: {', '.join(h.strip() for h in hits)})")
744
769
  # Emptiness is a defect only once the document claims to be finished.
745
770
  # A freshly seeded template is all headings and no content by design, and
746
771
  # a linter that fails on its own seed teaches people to skip the linter.
747
772
  approved = bool(re.search(r"\*\*Status:\*\*\s*approved", vision, re.IGNORECASE))
748
773
  if approved:
749
- for section in ("6. Anti-vision", "9. The alignment test"):
750
- body = re.split(rf"^##\s+{re.escape(section)}\s*$", vision, maxsplit=1,
751
- flags=re.MULTILINE)
774
+ for n in (6, 9):
775
+ body = re.split(_VSEC(n), vision, maxsplit=1, flags=re.MULTILINE)
752
776
  if len(body) == 2:
753
777
  tail = re.split(r"^##\s", body[1], maxsplit=1, flags=re.MULTILINE)[0]
754
778
  if not tail.strip():
755
- err(f"[U031] vision.md: approved but '## {section}' is empty — "
779
+ err(f"[U031] vision.md: approved but section {n} "
780
+ f"({VISION_SECTION_TITLES[n]}) is empty — "
756
781
  f"the section that settles arguments cannot be blank")
757
782
 
758
783
  # `B-005`: the seeded template is nine headings above HTML comments, and
@@ -762,9 +787,8 @@ def check_vision(ux: Path, vision: str) -> None:
762
787
  # installs starts arbitrating against a blank document. A warning rather
763
788
  # than an error, because a new project legitimately starts here: the defect
764
789
  # 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)
790
+ def _authored(n: int) -> bool:
791
+ parts = re.split(_VSEC(n), vision, maxsplit=1, flags=re.MULTILINE)
768
792
  if len(parts) != 2:
769
793
  return False
770
794
  tail = re.split(r"^##\s", parts[1], maxsplit=1, flags=re.MULTILINE)[0]
@@ -777,24 +801,39 @@ def check_vision(ux: Path, vision: str) -> None:
777
801
  tail = re.sub(r"^\s*(?:[-*+]|\d+\.)\s*$", "", tail, flags=re.MULTILINE)
778
802
  return bool(tail.strip())
779
803
 
780
- written = [s for s in VISION_SECTIONS if _authored(s)]
804
+ written = [n for n in VISION_SECTION_IDS if _authored(n)]
781
805
  if not approved and not written:
782
806
  warn(f"[U076] vision.md is still the seeded template — all "
783
- f"{len(VISION_SECTIONS)} sections are headings with nothing under "
807
+ f"{len(VISION_SECTION_IDS)} sections are headings with nothing under "
784
808
  f"them, so the alignment rule is arbitrating against a blank "
785
809
  f"document. Write it, or delete the file until you do")
786
810
 
787
811
  root = ux.parent.parent if ux.name == "ux" else ux.parent
788
- present = [root / n for n in INSTRUCTION_FILES if (root / n).is_file()]
789
- if not present:
790
- warn("[U032] vision.md exists but the project has no CLAUDE.md / AGENTS.md / "
791
- "GEMINI.md — the alignment rule has nowhere to live")
792
- return
793
- carrying = [p for p in present if VISION_RULE_HEADING in read(p)]
794
- if not carrying:
812
+ # FIX-UX-06.01 — the rule must live in the file the ACTIVE HOST reads, not
813
+ # in any of the three. A Codex project with the rule only in CLAUDE.md is
814
+ # uncovered: Codex reads AGENTS.md and never sees it.
815
+ hosts = active_hosts(root)
816
+ carrying = []
817
+ missing_file = [] # U032: the active host has no instruction file at all
818
+ missing_rule = [] # U033: the file exists but carries no rule block
819
+ for h in hosts:
820
+ target = root / HOST_TARGET[h]
821
+ if not target.is_file():
822
+ missing_file.append((h, HOST_TARGET[h]))
823
+ elif VISION_RULE_HEADING in read(target):
824
+ carrying.append(target)
825
+ else:
826
+ missing_rule.append((h, HOST_TARGET[h]))
827
+ if missing_file:
828
+ detail = ", ".join(f"{h} reads {f}" for h, f in missing_file)
829
+ warn(f"[U032] vision.md exists but the active host has no instruction file "
830
+ f"({detail}) — the alignment rule has nowhere the running host can read it")
831
+ if missing_rule:
832
+ detail = ", ".join(f"{f} ({h})" for h, f in missing_rule)
795
833
  warn(f"[U033] vision.md exists but no '{VISION_RULE_HEADING}' block in "
796
- f"{', '.join(p.name for p in present)} — nothing ever reads the vision "
834
+ f"{detail} — nothing the running host reads ever sees the vision "
797
835
  f"(run the `vision` skill's step 4)")
836
+ if not carrying:
798
837
  return
799
838
  for path in carrying:
800
839
  text = read(path)