super-ux 0.55.1 → 0.56.1
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 +51 -0
- package/bin/super-ux.js +85 -21
- package/package.json +3 -2
- package/plugins/super-ux/scripts/brand_lint.py +191 -48
- package/plugins/super-ux/scripts/ux_lint.py +70 -31
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
+
## 0.56.1 — the one contract that pointed at a neighbour
|
|
2
|
+
|
|
3
|
+
`vision/SKILL.md` named its section contract as `` `ux-scenarios/references/scenario-format.md` `` —
|
|
4
|
+
a path into a SIBLING skill, in backticks. The skills CLI ships only a skill's own
|
|
5
|
+
directory, so on Cursor, Codex, OpenClaw and the rest the contract arrived dangling;
|
|
6
|
+
`vision/references/` held a single file while the text pointed elsewhere. The four other
|
|
7
|
+
skills that use the same contract all write it as `](references/scenario-format.md)`.
|
|
8
|
+
|
|
9
|
+
- The link is now local, and `test/sync_references.py` ships `scenario-format.md` plus
|
|
10
|
+
its transitive closure (10 files) inside `vision/`.
|
|
11
|
+
- **The validator had a check for this and it was keyed to the wrong spelling.**
|
|
12
|
+
`validate_shipped_references` refused `../references/`, so a bare
|
|
13
|
+
`<sibling>/references/<file>.md` passed for as long as it existed. It now refuses any
|
|
14
|
+
path naming another skill's `references/`, keyed to the real sibling directory names so
|
|
15
|
+
a project output path like `docs/ux/…` cannot trip it — watched refusing the
|
|
16
|
+
pre-change wording before this shipped.
|
|
17
|
+
- `docs/brand/facts.md`: the stated validator check count recomputed, 4718 → 4821.
|
|
18
|
+
|
|
19
|
+
## 0.56.0 — the sherlock audit closes, and two prototype documents stop pretending to be one
|
|
20
|
+
|
|
21
|
+
Sherlock external-v3, 41 findings across this member, each one a commit carrying
|
|
22
|
+
its own executable regression under `test/audit_regressions/`. The ones worth
|
|
23
|
+
naming here:
|
|
24
|
+
|
|
25
|
+
- **Scenarios carry three axes, not one word.** `evidence_kind`,
|
|
26
|
+
`decision_status` and `validation_status` are separate: approval moves the
|
|
27
|
+
decision, and only a dated interview or observation moves validation. A founder
|
|
28
|
+
cannot promote a wished-for persona to `observed` by approving it.
|
|
29
|
+
- **Competitor funnels are observed exposure, never a proven base.** The reference
|
|
30
|
+
admitted a well-funded loss-maker looks profitable and then called common
|
|
31
|
+
patterns proven; it now says what it can see. Frequency is computed with a
|
|
32
|
+
denominator, duplicates collapsed — two funnels of one owner are ONE
|
|
33
|
+
observation — and adoption is decided by a local experiment.
|
|
34
|
+
- **`/ux-audit` preconditions are per scope.** A standalone blog with a brand pack
|
|
35
|
+
and no scenarios runs the copy audit instead of being routed into writing
|
|
36
|
+
scenarios it has no use for. Evidence is typed by claim: `file:line` for this
|
|
37
|
+
codebase, URL + timestamp + capture for anything outside it.
|
|
38
|
+
- **BP-212 no longer calls local payment testing impossible.** Three environments:
|
|
39
|
+
a local sandbox tests the whole payment→webhook→entitlement→success path with
|
|
40
|
+
the provider's own forwarding, and a public HTTPS endpoint is what PRODUCTION
|
|
41
|
+
delivery needs.
|
|
42
|
+
|
|
43
|
+
Two documents now share the ground one filename used to: `interactive-flow-prototypes.md`
|
|
44
|
+
is the machine-checkable graph contract (`IFP-01 … IFP-06`), and
|
|
45
|
+
`clickable-flow-prototypes.md` is the operator-facing practice that arrived with the
|
|
46
|
+
context-ready handoff. `prototyping.md` owns the decision to reach for either.
|
|
47
|
+
|
|
48
|
+
`ux-audit` and `ux-flows` were split back under the house working limit —
|
|
49
|
+
`audit-depth.md` and `prototyping.md` — after the audit's own doctrine grew them
|
|
50
|
+
past it. CI now MEASURES that budget with a real tokenizer instead of estimating it.
|
|
51
|
+
|
|
1
52
|
## 0.55.1 — the opt-out the router promises now lives in the bodies that honor it
|
|
2
53
|
|
|
3
54
|
Family audit 2026-09-06, wave AUDIT-WAVE-0906. **The operator's routing block
|
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,
|
|
29
|
-
//
|
|
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,
|
|
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
|
-
|
|
203
|
-
|
|
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,
|
|
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
|
|
245
|
-
if (status
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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.
|
|
3
|
+
"version": "0.56.1",
|
|
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
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
|
1171
|
+
known: set = set()
|
|
1097
1172
|
for row in rows:
|
|
1098
1173
|
if row["public"].lower() != "no":
|
|
1099
|
-
known |=
|
|
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
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
-
|
|
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}`
|
|
1143
|
-
f"
|
|
1144
|
-
f"
|
|
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(
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
"
|
|
695
|
-
"6
|
|
696
|
-
"
|
|
697
|
-
|
|
698
|
-
|
|
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
|
|
742
|
-
|
|
743
|
-
|
|
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
|
|
750
|
-
body = re.split(
|
|
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
|
|
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(
|
|
766
|
-
parts = re.split(
|
|
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 = [
|
|
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(
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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"{
|
|
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)
|